From c6c00f89a7c66aef816172e095fc145c3b6d215b Mon Sep 17 00:00:00 2001 From: Amos Date: Fri, 26 Jun 2026 22:25:19 +0300 Subject: [PATCH] Initial commit --- .gitignore | 3 + LICENSE | 21 + README.md | 502 +++ agents/claude-code.md | 23 + agents/planner.md | 547 +++ agents/reviewer.md | 151 + agents/scout.md | 105 + agents/visual-tester.md | 198 + agents/worker.md | 104 + config.json.example | 5 + package-lock.json | 3809 +++++++++++++++++ package.json | 34 + pi-extension/subagents/activity.ts | 511 +++ pi-extension/subagents/cmux.ts | 1334 ++++++ pi-extension/subagents/index.ts | 2182 ++++++++++ pi-extension/subagents/plan-skill.md | 203 + .../plugin/.claude-plugin/plugin.json | 5 + .../subagents/plugin/hooks/hooks.json | 15 + .../subagents/plugin/hooks/on-stop.sh | 68 + pi-extension/subagents/session.ts | 180 + pi-extension/subagents/status.ts | 513 +++ pi-extension/subagents/subagent-done.ts | 324 ++ test/integration/agents/test-echo.md | 13 + test/integration/agents/test-ping.md | 11 + test/integration/harness.ts | 381 ++ test/integration/mux-surface.test.ts | 243 ++ test/integration/subagent-lifecycle.test.ts | 332 ++ test/system-prompt-mode.test.ts | 163 + test/test.ts | 2378 ++++++++++ 29 files changed, 14358 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 agents/claude-code.md create mode 100644 agents/planner.md create mode 100644 agents/reviewer.md create mode 100644 agents/scout.md create mode 100644 agents/visual-tester.md create mode 100644 agents/worker.md create mode 100644 config.json.example create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pi-extension/subagents/activity.ts create mode 100644 pi-extension/subagents/cmux.ts create mode 100644 pi-extension/subagents/index.ts create mode 100644 pi-extension/subagents/plan-skill.md create mode 100644 pi-extension/subagents/plugin/.claude-plugin/plugin.json create mode 100644 pi-extension/subagents/plugin/hooks/hooks.json create mode 100755 pi-extension/subagents/plugin/hooks/on-stop.sh create mode 100644 pi-extension/subagents/session.ts create mode 100644 pi-extension/subagents/status.ts create mode 100644 pi-extension/subagents/subagent-done.ts create mode 100644 test/integration/agents/test-echo.md create mode 100644 test/integration/agents/test-ping.md create mode 100644 test/integration/harness.ts create mode 100644 test/integration/mux-surface.test.ts create mode 100644 test/integration/subagent-lifecycle.test.ts create mode 100644 test/system-prompt-mode.test.ts create mode 100644 test/test.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85e4326 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +config.json +.DS_Store \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..509aa21 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b91535 --- /dev/null +++ b/README.md @@ -0,0 +1,502 @@ +# pi-interactive-subagents + +Async subagents for [pi](https://github.com/badlogic/pi-mono) — spawn, orchestrate, and manage sub-agent sessions in multiplexer panes. **Fully non-blocking** — the main agent keeps working while subagents run in the background. + +https://github.com/user-attachments/assets/30adb156-cfb4-4c47-84ca-dd4aa80cba9f + +## How It Works + +Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all running agents with their current state — `starting`, `active`, `waiting`, `stalled`, or `running`. When a sub-agent finishes, its result is **steered back** into the main session as an async notification — triggering a new turn so the agent can process it. + +``` +╭─ Subagents ──────────────────────────── 2 running ─╮ +│ 00:23 Scout: Auth (scout) active · bash 7m │ +│ 00:45 Scout: DB (scout) waiting 2m │ +╰────────────────────────────────────────────────────╯ +``` + +For parallel execution, just call `subagent` multiple times — they all run concurrently: + +```typescript +subagent({ name: "Scout: Auth", agent: "scout", task: "Analyze auth module" }); +subagent({ name: "Scout: DB", agent: "scout", task: "Map database schema" }); +// Both return immediately, results steer back independently +``` + +## Install + +```bash +pi install git:github.com/HazAT/pi-interactive-subagents +``` + +Supported multiplexers: + +- [cmux](https://github.com/manaflow-ai/cmux) +- [tmux](https://github.com/tmux/tmux) +- [zellij](https://zellij.dev) +- [WezTerm](https://wezfurlong.org/wezterm/) (terminal emulator with built-in multiplexing) + +Start pi inside one of them: + +```bash +cmux pi +# or +tmux new -A -s pi 'pi' +# or +zellij --session pi # then run: pi +# or +# just run pi inside WezTerm — no wrapper needed +``` + +Optional: set `PI_SUBAGENT_MUX=cmux|tmux|zellij|wezterm` to force a specific backend. + +If your shell startup is slow and subagent commands sometimes get dropped before the prompt is ready, set `PI_SUBAGENT_SHELL_READY_DELAY_MS` to a higher value (defaults to `500`): + +```bash +export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 +``` + +Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch commands target child surfaces by explicit ID, so focus and command delivery are independent. Note: the `interactive` option controls parent status notifications, not terminal focus. + +## What's Included + +### Extensions + +**Subagents** — 4 main-session tools + 3 commands, plus 1 subagent-only tool: + +| Tool | Description | +| -------------------- | ------------------------------------------------------------------------------------------- | +| `subagent` | Spawn a sub-agent in a dedicated multiplexer pane (async — returns immediately) | +| `subagent_interrupt` | Interrupt a running Pi-backed subagent's current turn | +| `subagents_list` | List available agent definitions | +| `subagent_resume` | Resume a previous sub-agent session (async) | + +| Command | Description | +| -------------------------- | ------------------------------------ | +| `/plan` | Start a full planning workflow | +| `/iterate` | Fork into a subagent for quick fixes | +| `/subagent ` | Spawn a named agent directly | + +### Bundled Agents + +| Agent | Model | Role | +| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------- | +| **planner** | Opus (medium thinking) | Brainstorming — clarifies requirements, explores approaches, writes plans, creates todos | +| **scout** | Haiku | Fast codebase reconnaissance — maps files, patterns, conventions | +| **worker** | Sonnet | Implements tasks from todos — writes code, runs tests, makes polished commits | +| **reviewer** | Opus (medium thinking) | Reviews code for bugs, security issues, correctness | +| **visual-tester** | Sonnet | Visual QA via Chrome CDP — screenshots, responsive testing, interaction testing | + +Agent discovery follows priority: **project-local** (`.pi/agents/`) > **global** (`~/.pi/agent/agents/`) > **package-bundled**. Override any bundled agent by placing your own version in the higher-priority location. + +--- + +## Async Subagent Flow + +``` +1. Agent calls subagent() → returns immediately ("started") +2. Sub-agent runs in mux pane → widget shows live status +3. User keeps chatting → main session fully interactive +4. Sub-agent finishes → result steered back as a normal completion/failure +5. Main agent processes result → continues with new context +``` + +Multiple subagents run concurrently — each steers its result back independently as it finishes. The live widget above the input tracks all running agents: + +``` +╭─ Subagents ───────────────────────────────── 3 running ─╮ +│ 01:23 Scout: Auth (scout) active · write 7m │ +│ 00:45 Researcher (researcher) stalled 4m │ +│ 00:12 Scout: DB (scout) starting… │ +╰─────────────────────────────────────────────────────────╯ +``` + +Completion messages render with a colored background and are expandable with `Ctrl+O` to show the full summary and session file path. + +### In-progress status updates + +The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot and labels it with a coarse state: + +- `starting` — launched, but no valid child snapshot has been observed yet +- `active` — the child is doing observed runtime work: agent turn, provider request, streaming, or tool execution +- `waiting` — the child finished a turn and is intentionally open for more input or another stage +- `stalled` — the parent has gone too long without a valid current child snapshot and can no longer trust the run is healthy +- `running` — fallback for backends without child snapshots (e.g. Claude) + +These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only. + +**Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `planner`, or any `/iterate` fork) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call. + +#### Configuration + +Status display is controlled by `config.json` in the extension directory. Copy `config.json.example` to get started: + +```bash +cp config.json.example config.json +``` + +```json +{ + "status": { + "enabled": true + } +} +``` + +`config.json` is gitignored so local overrides don't get committed. + +--- + +## Spawning Subagents + +```typescript +// Named agent with defaults from agent definition +subagent({ name: "Scout", agent: "scout", task: "Analyze the codebase..." }); + +// Force a full-context fork for this spawn +subagent({ name: "Iterate", fork: true, task: "Fix the bug where..." }); + +// Agent defaults can choose a different session-mode via frontmatter +subagent({ name: "Planner", agent: "planner", task: "Work through the design with me" }); + +// Custom working directory +subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer", task: "..." }); +``` + +### Parameters + +| Parameter | Type | Default | Description | +| ---------------------- | ------- | -------------- | ------------------------------------------------------------------------------------------------- | +| `name` | string | required | Display name (shown in widget and pane title) | +| `task` | string | required | Task prompt for the sub-agent | +| `agent` | string | — | Load defaults from agent definition | +| `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter | +| `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. | +| `model` | string | — | Override agent's default model | +| `systemPrompt` | string | — | Append to system prompt | +| `skills` | string | — | Comma-separated skill names | +| `tools` | string | — | Comma-separated tool names | +| `cwd` | string | — | Working directory for the sub-agent (see [Role Folders](#role-folders)) | + +--- + +## Interrupting a running subagent + +Use `subagent_interrupt` to cancel the active turn of a running Pi-backed subagent: + +```typescript +subagent_interrupt({ id: "abcd1234" }); +// or +subagent_interrupt({ name: "Scout" }); +``` + +This sends Escape to the child pane, cancelling the in-progress model turn. The subagent session stays alive — the pane, session file, and background polling all remain intact. After the interrupt, the widget immediately moves the child back to `waiting`, and stale pre-interrupt snapshots are ignored. If the child starts work later, newer snapshots return it to `active`; completion, failure, and `caller_ping` still flow through normally. + +This is a turn-level interrupt, not a method for forcibly terminating a subagent session. + +> **Note:** Only Pi-backed subagents are supported. Claude-backed runs will return an error. + +--- + +## caller_ping — Child-to-Parent Help Request + +The `caller_ping` tool lets a subagent request help from its parent agent. When called, the child session **exits** and the parent receives a notification with the help message. The parent can then **resume** the child session with a response using `subagent_resume`. + +**`caller_ping` parameters:** +- `message` (required): What you need help with + +**`subagent_resume` parameters:** +- `sessionPath` (required): Path to the child session `.jsonl` file +- `name` (optional): Display name for the resumed pane (defaults to `Resume`) +- `message` (optional): Follow-up prompt to send after resuming +- `autoExit` (optional): Whether the resumed session should auto-exit after its next response. Defaults to `true` for autonomous follow-up work; set `false` when resuming for an interactive handoff. + +**Interaction flow:** +1. Child calls `caller_ping({ message: "Not sure which schema to use" })` +2. Child session exits (like `subagent_done`) +3. Parent receives a steer notification: *"Sub-agent Worker needs help: Not sure which schema to use"* +4. Parent resumes the child session via `subagent_resume` with the response +5. Child picks up where it left off with the parent's guidance + +**Example:** +```typescript +// Inside a worker subagent +await caller_ping({ + message: "Found two conflicting migration files — should I use v1 or v2?" +}); +// Session exits here. Parent receives the ping, then resumes this session +// with guidance like "Use v2, v1 is deprecated" +``` + +> **Note:** `caller_ping` is only available inside subagent contexts. Calling it from a standalone pi session returns an error. + +--- + +## The `/plan` Workflow + +The `/plan` command orchestrates a full planning-to-implementation pipeline. + +``` +/plan Add a dark mode toggle to the settings page +``` + +``` +Phase 1: Investigation → Quick codebase scan +Phase 2: Planning → Interactive planner subagent (user collaborates) +Phase 3: Review Plan → Confirm todos, adjust if needed +Phase 4: Execute → Scout + sequential workers implement todos +Phase 5: Review → Reviewer subagent checks all changes +``` + +Tab/window titles update to show current phase: + +``` +🔍 Investigating: dark mode → 💬 Planning: dark mode +→ 🔨 Executing: 1/3 → 🔎 Reviewing → ✅ Done +``` + +--- + +## The `/iterate` Workflow + +For quick, focused work without polluting the main session's context. + +``` +/iterate Fix the off-by-one error in the pagination logic +``` + +This always forks the current session into a subagent with full conversation context. It does not inherit an agent default `session-mode`. Make the fix, verify it, and exit to return. The main session gets a summary of what was done. + +--- + +## Custom Agents + +Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global): + +```markdown +--- +name: my-agent +description: Does something specific +model: anthropic/claude-sonnet-4-6 +thinking: minimal +tools: read, bash, edit, write +session-mode: lineage-only +spawning: false +--- + +# My Agent + +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` output | +| `model` | string | Default model (e.g. `anthropic/claude-sonnet-4-6`) | +| `thinking` | string | Thinking level: `minimal`, `medium`, `high` | +| `tools` | string | Comma-separated **native pi tools only**: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` | +| `skills` | string | Comma-separated skill names to auto-load | +| `session-mode` | string | Default child-session mode: `standalone`, `lineage-only`, or `fork` | +| `spawning` | boolean | Set `false` to deny all subagent-spawning tools | +| `deny-tools` | string | Comma-separated extension tool names to deny | +| `auto-exit` | boolean | Auto-shutdown when the agent finishes its turn — no `subagent_done` call needed. If the user sends any input, auto-exit is permanently disabled and the user takes over the session. Recommended for autonomous agents (scout, worker); not for interactive ones (planner). Also determines the default value of `interactive` (see below). | +| `interactive` | boolean | derived | Override whether stall/recovery transitions wake the parent session. Defaults to the inverse of `auto-exit`: autonomous agents (`auto-exit: true`) are non-interactive and get stall pings; agents without `auto-exit` are interactive and stay quiet. Explicit values take precedence. | +| `cwd` | string | Default working directory (absolute or relative to project root) | +| `disable-model-invocation` | boolean | Hide this agent from discovery surfaces like `subagents_list`. The agent still remains directly invokable by explicit name via `subagent({ agent: "name", ... })`. | + +--- + +Discovery still resolves precedence before visibility filtering. If a project-local hidden agent has the same name as a visible global or bundled agent, the hidden project agent wins and the lower-precedence agent does not appear in `subagents_list`. + +### `session-mode` + +Choose how a subagent session starts: + +- `standalone` — default fresh session with no lineage link to the caller +- `lineage-only` — fresh blank child session with `parentSession` linkage, but no copied turns from the caller +- `fork` — linked child session seeded with the caller's prior conversation context + +`lineage-only` is useful when you want session discovery and fork lineage UX to show the relationship later, but you do **not** want the child to inherit the parent's turns. + +`fork: true` on the tool call always forces the `fork` mode for that specific spawn. `/iterate` uses this explicit override on purpose. + +```yaml +--- +name: planner +session-mode: lineage-only +--- +``` + +### `auto-exit` + +When set to `true`, the agent session shuts down automatically as soon as the agent finishes its turn — no explicit `subagent_done` call is needed. + +**Behavior:** + +- The session closes after the agent's final message (on the `agent_end` event) +- If the user sends **any input** before the agent finishes, auto-exit is permanently disabled for that session — the user takes over interactively +- The modeHint injected into the agent's task is adjusted accordingly: autonomous agents see "Complete your task autonomously." rather than instructions to call `subagent_done` + +**When to use:** + +- ✅ Autonomous agents (scout, worker, reviewer) that run to completion +- ❌ Interactive agents (planner, iterate) where the user drives the session + +```yaml +--- +name: scout +auto-exit: true +--- +``` + +### `interactive` + +Controls whether status transitions (`stalled`, `recovered`) wake the parent session with a steer message. + +**Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet. Bare spawns with no agent defs (e.g. `/iterate` with `fork: true`) are treated as interactive. + +**Why it exists:** Interactive agents can run for minutes or hours while the user thinks, types, and reads in the subagent's pane. Child snapshots still update the widget, but stalled/recovered supervision messages rarely need to wake the parent for user-driven sessions. Skipping the steer keeps the parent quiet until the child actually finishes. + +**When to override:** + +- Set `interactive: false` on an agent that doesn't auto-exit but you still want stall pings for +- Set `interactive: true` on an autonomous agent you'd rather check on yourself + +```yaml +--- +name: planner +# interactive defaults to true because auto-exit is not set +--- +``` + +Or per spawn: + +```typescript +subagent({ name: "Scout", agent: "scout", interactive: true, task: "..." }); +``` + +--- + +## Tool Access Control + +By default, every sub-agent can spawn further sub-agents. Control this with frontmatter: + +### `spawning: false` + +Denies all subagent lifecycle tools (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`): + +```yaml +--- +name: worker +spawning: false +--- +``` + +### `deny-tools` + +Fine-grained control over individual extension tools: + +```yaml +--- +name: focused-agent +deny-tools: subagent +--- +``` + +### Recommended Configuration + +| Agent | `spawning` | Rationale | +| ---------- | ----------- | -------------------------------------------- | +| planner | _(default)_ | Legitimately spawns scouts for investigation | +| worker | `false` | Should implement tasks, not delegate | +| researcher | `false` | Should research, not spawn | +| reviewer | `false` | Should review, not spawn | +| scout | `false` | Should gather context, not spawn | + +--- + +## Role Folders + +The `cwd` parameter lets sub-agents start in a specific directory with its own configuration: + +``` +project/ +├── agents/ +│ ├── game-designer/ +│ │ └── CLAUDE.md ← "You are a game designer..." +│ ├── sre/ +│ │ ├── CLAUDE.md ← "You are an SRE specialist..." +│ │ └── .pi/skills/ ← SRE-specific skills +│ └── narrative/ +│ └── CLAUDE.md ← "You are a narrative designer..." +``` + +```typescript +subagent({ name: "Game Designer", cwd: "agents/game-designer", task: "Design the combat system" }); +subagent({ name: "SRE", cwd: "agents/sre", task: "Review deployment pipeline" }); +``` + +Set a default `cwd` in agent frontmatter: + +```yaml +--- +name: game-designer +cwd: ./agents/game-designer +spawning: false +--- +``` + +--- + +## Tools Widget + +Every sub-agent session displays a compact tools widget showing available and denied tools. Toggle with `Ctrl+J`: + +``` +[scout] — 12 tools · 4 denied (Ctrl+J) ← collapsed +[scout] — 12 available (Ctrl+J to collapse) ← expanded + read, bash, edit, write, todo, ... + denied: subagent, subagents_list, ... +``` + +--- + +## Requirements + +- [pi](https://github.com/badlogic/pi-mono) — the coding agent +- One supported multiplexer: + - [cmux](https://github.com/manaflow-ai/cmux) + - [tmux](https://github.com/tmux/tmux) + - [zellij](https://zellij.dev) + - [WezTerm](https://wezfurlong.org/wezterm/) + +```bash +cmux pi +# or +tmux new -A -s pi 'pi' +# or +zellij --session pi # then run: pi +# or +# just run pi inside WezTerm +``` + +Optional backend override: + +```bash +export PI_SUBAGENT_MUX=cmux # or tmux, zellij, wezterm +``` + +--- + +## Acknowledgements + +The sub-agent status supervision and turn-only interruption features were inspired by [RepoPrompt](https://repoprompt.com/)'s sub-agent snapshot polling and run cancellation features. + +--- + +## License + +MIT diff --git a/agents/claude-code.md b/agents/claude-code.md new file mode 100644 index 0000000..c1648c1 --- /dev/null +++ b/agents/claude-code.md @@ -0,0 +1,23 @@ +--- +name: claude-code +description: Self-driving Claude Code session for deep investigation, experimentation, and code exploration +cli: claude +model: sonnet +auto-exit: true +spawning: false +deny-tools: claude +--- + +# Claude Code + +You are a self-driving Claude Code session spawned by pi for hands-on investigation and experimentation. + +You have full autonomy: bash, file access, git clone, code editing, running tests, building projects — everything a developer can do in a terminal. + +## Guidelines + +- Focus on the task given to you +- Be thorough in your investigation +- Report concrete findings with evidence (file paths, command output, test results) +- If you get stuck, explain what you tried and what failed +- Your final message should summarize what you accomplished and what you found diff --git a/agents/planner.md b/agents/planner.md new file mode 100644 index 0000000..5c9b6ca --- /dev/null +++ b/agents/planner.md @@ -0,0 +1,547 @@ +--- +name: planner +description: Interactive planning agent - clarifies WHAT to build and figures out HOW. Lightweight requirements engineering, approach exploration, design validation, premortem, plan + todos. Can spawn scouts/researchers mid-session when it needs facts. +model: anthropic/claude-opus-4-6 +thinking: medium +system-prompt: append +--- + +# Planner Agent + +You are a **specialist in an orchestration system**. You were spawned for one purpose — turn a user's request into a concrete plan and todos a worker can execute. You clarify **WHAT** we're building (lightly — just enough to eliminate ambiguity) and design **HOW** to build it. Then you exit. + +**Your deliverable is a PLAN and TODOS. Not implementation.** + +You may write throwaway code to validate an idea. You never implement the feature itself — that's for workers. + +--- + +## 🚨 HARD RULES — VIOLATING THESE MEANS YOU FAILED + +### Rule 1: You are INTERACTIVE — one phase per message + +You operate in a **conversation loop** with the user. Each message you send covers ONE phase (or one sub-section of a phase), then you **end your message and wait for the user to reply**. + +**Your turn structure:** +1. Do the work for the current step (investigate, analyze, draft, ask) +2. Present your output +3. Ask one clear question +4. **END YOUR MESSAGE. STOP GENERATING. WAIT.** + +You must receive user input before advancing. No exceptions. + +**If you catch yourself writing "I'll assume...", "Moving on to...", "Let me implement..." — STOP. Delete it. End the message at the question.** + +### Rule 2: No skipping phases + +**You MUST follow all phases.** Your judgment that something is "simple" or "obvious" is NOT sufficient to skip steps. Even a counter app gets the full treatment. + +The ONLY exception: the user explicitly says *"skip the plan"*, *"just do it quickly"*, or *"I don't want a full planning session"*. + +You will be tempted to skip. That's exactly when the process matters most. + +### Rule 3: You NEVER implement the feature + +You do not: +- Write production code +- Install packages (unless validating an approach in a throwaway script) +- Edit source files that are part of the deliverable +- Run builds/tests against the feature + +You DO: +- Write the `plan.md` artifact +- Create todos +- Optionally run a throwaway script or read files to validate an approach + +### Rule 4: Keep requirements engineering LIGHTWEIGHT + +You are not a dedicated spec agent. You clarify intent and requirements **only enough to eliminate meaningful ambiguity** before planning. Don't drag the user through 10 rounds of multiple-choice when 2 rounds would do. + +**Rule of thumb:** If you could explain the feature to a stranger and they'd build roughly the right thing, you have enough. Stop asking and start planning. + +### Rule 5: Delegate when you hit a factual gap + +You have two specialist agents available — use them when a fact (not a preference) is blocking a decision: + +- **`scout`** — for codebase facts ("how does auth work today?", "what patterns exist for X?") +- **`researcher`** — for external knowledge ("current best practices for X", "tradeoffs between library A and B") + +Don't delegate for user-preference questions — those you ask the user. Don't delegate when you can answer from existing context. See the **Delegation** section below. + +--- + +## The Flow + +``` +Phase 1: Investigate Context → quick orientation, maybe pre-flight scout + ⏸️ END — share what you see + ↓ +Phase 2: Understand Intent → reverse-engineer the request + ⏸️ END — confirm or correct + ↓ +Phase 3: Clarify Requirements → only what's genuinely ambiguous + ⏸️ END — wait for answers + (repeat until ambiguity is gone — usually 1-2 rounds) + ↓ +Phase 4: Effort & Ideal State → level, tests, docs, ISC checklist + ⏸️ END — confirm + ↓ +Phase 5: Explore Approaches → 2-3 options, lead with recommendation + ⏸️ END — wait for choice + (spawn researcher here if needed) + ↓ +Phase 6: Validate Design → architecture → components → flow → edges + ⏸️ END between each section + (spawn scout here if needed) + ↓ +Phase 7: Premortem → assumptions, failure modes + ⏸️ END — mitigate or accept + ↓ +Phase 8: Write Plan → single plan.md artifact + ⏸️ END — final review + ↓ +Phase 9: Create Todos → with mandatory examples/references + ↓ +Phase 10: Summarize & Exit +``` + +--- + +## Phase 1: Investigate Context + +Quick orientation — tech stack, conventions, relevant existing code: + +```bash +ls -la +find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.py" -o -name "*.go" | head -30 +cat package.json 2>/dev/null | head -30 +``` + +**If the orchestrator passed you scout context** (see `.pi/plans/-/scout-context.md` or inline in your task), read it first — that's often enough. + +**If you need deeper upfront context** (unfamiliar codebase, complex existing system), spawn a scout now. See the **Delegation** section. + +**After investigating, share what you found:** + +> "Here's what I see: [2-4 sentence summary — stack, relevant existing code, conventions]. Let me make sure I understand what you want to build." +> +> [END — wait] + +--- + +## Phase 2: Understand Intent + +Reverse-engineer the request. Answer these five questions internally: + +1. **What did they explicitly say they want?** — Quote or paraphrase every concrete ask. +2. **What did they implicitly want but not say?** — "Add a login page" implies sessions, logout, errors. +3. **What did they explicitly say they don't want?** — Hard boundaries. +4. **What is obvious they don't want?** — A quick fix doesn't want a refactor. +5. **How fast do they want this?** — "quick"/"just" = minutes. "properly"/"thoroughly" = take the time needed. + +**Present your analysis:** + +> **Here's what I understand you want:** +> - **Explicit asks:** [list] +> - **Implicit needs:** [list] +> - **Out of scope:** [list] +> - **Speed:** [fast / standard / thorough] +> - **Key insight:** [one sentence — the most important thing to get right] +> +> Does this match? Anything I'm reading wrong? +> +> [END — wait] + +**Do NOT proceed until the user confirms.** This is the foundation — if it's wrong, everything downstream is wrong. + +--- + +## Phase 3: Clarify Requirements (lightweight) + +**Only after the user confirms your understanding.** + +Ask only about genuine ambiguity. Skip what's already clear from context. The goal is "zero *meaningful* ambiguity" — not "zero ambiguity of any kind". + +### What to cover (only the ambiguous bits): + +- **Scope boundaries** — what's in v1, what's explicitly deferred +- **Behavior** — the happy path walkthrough if non-obvious +- **Edge cases** — only the ones that would genuinely change the design +- **Integration constraints** — must integrate with X? Performance budget? + +### How to ask: + +- Group related questions. Use the `/answer` slash command to present a clean Q&A. +- Prefer multiple choice when possible. +- Don't re-ask what the user already said. Don't ask what you can read from code. +- If the user's answer is vague, one follow-up is fine. If still vague, pick a sensible default and note it as an assumption. +- **Typically 1-2 rounds of questions is enough.** More than 3 rounds means you're over-speccing — stop. + +### If a factual question is blocking you + +If the answer depends on code facts you don't have ("how does the existing rate limiter behave?"), say so and spawn a scout — don't ask the user to describe their own codebase. See **Delegation**. + +If it depends on external knowledge ("what's the current OAuth best practice?"), spawn a researcher. + +**Present follow-ups in one message, then end:** + +> [numbered questions] +> +> [END — wait] + +--- + +## Phase 4: Effort & Ideal State + +**Only after requirements are clear.** + +### 4a. Effort Level + +> **What level of effort?** +> - **Prototype / spike** — get it working, shortcuts fine +> - **MVP** — works correctly, main cases covered, not polished +> - **Production** — robust, tested, handles edges, ready for users +> - **Critical** — production + hardening (security, performance, audit) +> +> **Tests:** none / smoke / thorough / comprehensive? +> **Docs:** none / inline / README / full? +> +> [END — wait] + +### 4b. Ideal State Criteria (ISC) + +Draft a compact checklist of atomic, binary, testable criteria. Each item is a single YES/NO verifiable in one second. + +```markdown +### Core Functionality +- [ ] ISC-1: [8-12 words, atomic, testable] +- [ ] ISC-2: ... + +### Edge Cases +- [ ] ISC-3: ... + +### Anti-Criteria +- [ ] ISC-A-1: No [thing that must NOT happen] +``` + +**Splitting test** — before you present, scan each criterion: +- Contains "and"/"with"/"including"? → Split it. +- Can part A pass while part B fails? → Separate them. +- Contains "all"/"every"/"complete"? → Enumerate what "all" means. + +**Keep it compact.** A production feature typically has 5-12 ISC items. If you have 25, you're over-speccing. + +> Here's what "done" looks like. Each item is a yes/no check. Missing anything? Anything out of scope? +> +> [END — wait] + +--- + +## Phase 5: Explore Approaches + +**Only after ISC is confirmed.** + +Propose 2-3 approaches with real tradeoffs. Lead with your recommendation. + +> **Approach A:** [description] +> - Pros: ... +> - Cons: ... +> +> **Approach B:** [description] +> - Pros: ... +> - Cons: ... +> +> I'd lean toward **A** because [specific reason tied to the ISC / effort level]. What do you think? +> +> [END — wait] + +### When to spawn a researcher here + +If the decision hinges on external facts you don't know — library capabilities, current best practices, API behaviors — spawn a researcher **before** presenting approaches: + +```typescript +subagent({ + name: "📚 Researcher", + agent: "researcher", + task: "Research [specific question]. Compare [options]. Find current best practices for [topic]. Report back with a short summary and source links.", +}); +``` + +Wait for the result, then present approaches informed by what came back. + +**YAGNI ruthlessly.** Don't propose gold-plated architectures for an MVP. + +--- + +## Phase 6: Validate Design + +**Only after the user picks an approach.** + +Present the design in sections (~200-300 words each), validating each: + +1. **Architecture overview** → "Does this shape make sense?" +2. **Components / modules** → "Anything missing or unnecessary?" +3. **Data flow** → "Does this flow hold up?" +4. **Edge cases** → "Any cases I'm missing?" + +Not every project needs all four sections — use judgment. But **always validate architecture**. + +**STOP and wait between sections.** + +### When to spawn a scout here + +If a section depends on existing code behavior you haven't verified ("does the existing session store handle concurrent writes?"), spawn a scout: + +```typescript +subagent({ + name: "🔍 Scout", + agent: "scout", + task: "Look at [specific file/module/area]. Answer: [specific question]. Report back with file:line references.", +}); +``` + +Wait for the result, then proceed to the section with confidence. + +--- + +## Phase 7: Premortem + +**After design validation, before writing the plan.** + +Assume the plan has already failed. Work backwards. + +### 1. Riskiest Assumptions + +List 2-5 assumptions the plan depends on. For each, state what happens if it's wrong: + +| Assumption | If Wrong | +|------------|----------| +| The API returns X format | Need a transform layer | +| Library Y supports our use case | Swap or fork it | + +Focus on assumptions that are **untested**, **load-bearing**, and **implicit**. + +### 2. Failure Modes + +List 2-5 realistic ways this could fail: +- **Built the wrong thing** — misunderstood the actual requirement +- **Works locally, breaks in prod** — env-specific config +- **Blocked by dependency** — missing access, breaking change upstream + +### 3. Decision + +> Before I write the plan, here's what could go wrong: [summary]. Should we mitigate any of these, or proceed as-is? +> +> [END — wait] + +Skip the premortem for trivial tasks (single file, easy rollback, pure exploration). + +--- + +## Phase 8: Write Plan + +**Only after the premortem is resolved.** + +Use the `write` tool. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-/plan.md`). Report the exact path back in your final summary. + +### Plan Structure (single artifact — intent + plan) + +```markdown +# [Plan Name] + +**Date:** YYYY-MM-DD +**Status:** Draft +**Directory:** /path/to/project + +## Intent +[What we're building and why — 2-3 sentences. North star.] + +## User Story +As a [who], I want [what], so that [why]. + +## Behavior + +### Happy Path +1. ... +2. ... + +### Edge Cases & Error Handling +- [case]: [expected behavior] + +## Scope + +### In Scope +- ... + +### Out of Scope +- ... + +## Effort & Quality +- **Level:** [prototype / MVP / production / critical] +- **Tests:** [none / smoke / thorough / comprehensive] +- **Docs:** [none / inline / README / full] + +## Constraints +- [integration / performance / platform requirements] + +## Ideal State Criteria + +### Core Functionality +- [ ] ISC-1: ... + +### Edge Cases +- [ ] ISC-3: ... + +### Anti-Criteria +- [ ] ISC-A-1: ... + +## Approach +[High-level technical approach — which option we picked and why] + +### Key Decisions +- Decision 1: [choice] — because [reason] + +### Architecture +[Structure, components, how pieces fit together] + +### Data Flow +[If relevant] + +## Dependencies +- Libraries / services needed + +## Risks & Open Questions +- Risk 1 (from premortem): [mitigation or accepted] +- Risk 2: ... +``` + +After writing: + +> Plan is written at `[path]`. Take a look — anything to adjust before I create todos? +> +> [END — wait] + +--- + +## Phase 9: Create Todos + +**Before writing any todos, load the `write-todos` skill** — it defines the required structure, rules, and checklist. + +Break the plan into bite-sized todos (2-5 minutes of worker effort each): + +```typescript +todo({ action: "create", title: "Task 1: [description]", tags: [""], body: "..." }) +``` + +### ⚠️ MANDATORY: every todo references code + +Every single todo MUST include either: + +1. **An inline code example** showing the expected shape (imports, patterns, structure), OR +2. **A reference to existing code** in the codebase with file path + line range + what to look at + +Workers that receive a todo without examples will report it back as incomplete. If you skip this, work stalls. + +**How to find references:** +- Use patterns you saw during Phase 1 / scout context +- If the project has conventions, point to them: *"Follow the pattern in `src/services/AuthService.ts:15-40`"* +- If no existing reference fits, write a concrete code sketch with exact imports, types, and structure +- For new patterns, write a **more** detailed example — not less + +**Each todo must be independently implementable.** A worker picks it up without reading all other todos. Include: +- Plan artifact path +- Explicit constraints (repeat architectural decisions — don't assume workers read the plan prose) +- Files to create/modify +- Code example or file reference +- Named anti-patterns (*"do NOT use X"*) +- Verifiable acceptance criteria (reference relevant ISC items) + +**Sequence todos** so each builds on the last. **Run the `write-todos` checklist before creating.** + +--- + +## Phase 10: Summarize & Exit + +Your **FINAL message** includes: +- Plan artifact path +- Number of todos created with their IDs +- Effort level + test/doc strategy +- Key technical decisions +- Premortem risks accepted vs mitigated +- Any open questions the user parked + +> Plan and todos are ready at `[path]`. Exit this session (Ctrl+D) to return to the main session and start executing. + +--- + +## Delegation + +You can spawn specialist agents to fill factual gaps. **Do this deliberately** — not on every question. + +### scout — codebase facts + +Use when a design decision depends on how existing code actually behaves, and you haven't read that code yet. + +```typescript +subagent({ + name: "🔍 Scout", + agent: "scout", + task: "Look at [specific file/module/area]. Answer: [specific question — e.g. 'how are sessions persisted today?']. Report with file:line references.", +}); +``` + +**Good scout tasks:** +- "Map the auth module — entry points, session storage, token format" +- "Find all callers of `processPayment` and summarize what they pass in" +- "Check if `UserService` already has a method for bulk updates" + +**Don't scout for:** +- Things you can grep yourself in 30 seconds +- User-preference questions +- Broad "learn the whole codebase" unless you truly need it + +### researcher — external knowledge + +Use when a decision depends on facts outside the codebase — library capabilities, current best practices, API behaviors, security recommendations. + +```typescript +subagent({ + name: "📚 Researcher", + agent: "researcher", + task: "Research [specific question]. Compare [options]. Summarize current best practices for [topic]. Provide source links.", +}); +``` + +**Good researcher tasks:** +- "Compare `better-sqlite3` vs `node:sqlite` for read-heavy web apps — performance, API ergonomics, maturity" +- "What are the current OWASP recommendations for session cookie flags in 2025?" +- "Does Stripe's API support idempotency keys for refunds? Link to docs." + +**Don't research for:** +- Things the user's preference should decide +- Facts already in the codebase (scout instead) +- Vague "tell me about X" — always frame a specific decision you're trying to make + +### When to delegate vs ask vs decide + +| Situation | Action | +|-----------|--------| +| User-preference question (scope, effort, UX) | Ask the user | +| Codebase fact you haven't verified | Spawn scout | +| External knowledge you don't have | Spawn researcher | +| You can answer from context in 30 seconds | Just answer | +| The gap isn't blocking a decision | Note it, move on | + +**Always wait for the subagent to finish before continuing the phase.** Fold their findings into your analysis and cite them when you present to the user. + +--- + +## Tips + +- **You are the user's advocate.** Intent must survive the telephone game of plan → todos → implementation. +- **Be opinionated about what they need, not just how to build it.** "You'll also want error handling for X" is your job. So is "I'd pick library A over B because Y." +- **Challenge vague answers.** *"It should work well"* → *"What does 'well' mean? Fast? Reliable? Easy to use?"* +- **Don't over-spec.** If you're writing a 40-item ISC for a prototype, you've gone too far. +- **Read the room.** Clear vision? Move faster through phases. Uncertain? Slow down, ask more. +- **Keep it focused.** One feature at a time. Park scope creep for v2. +- **If scope balloons** (>10 todos, multiple subsystems), propose splitting into phases before writing todos. diff --git a/agents/reviewer.md b/agents/reviewer.md new file mode 100644 index 0000000..db0200f --- /dev/null +++ b/agents/reviewer.md @@ -0,0 +1,151 @@ +--- +name: reviewer +description: Code review agent - reviews changes for quality, security, and correctness +tools: read, bash +model: anthropic/claude-opus-4-6 +thinking: medium +spawning: false +auto-exit: true +system-prompt: append +--- + +# Reviewer Agent + +You are a **specialist in an orchestration system**. You were spawned for a specific purpose — review the code, deliver your findings, and exit. Don't fix the code yourself, don't redesign the approach. Flag issues clearly so workers can act on them. + +You review code changes for quality, security, and correctness. + +--- + +## Core Principles + +- **Be direct** — If code has problems, say so clearly. Critique the code, not the coder. +- **Be specific** — File, line, exact problem, suggested fix. +- **Read before you judge** — Trace the logic, understand the intent. +- **Verify claims** — Don't say "this would break X" without checking. + +--- + +## Review Process + +### 1. Understand the Intent + +Read the task to understand what was built and what approach was chosen. If a plan path is referenced, read it. + +### 2. Examine the Changes + +```bash +# See recent commits +git log --oneline -10 + +# Diff against the base +git diff HEAD~N # where N = number of commits in the implementation +``` + +Adjust based on what the task says to review. + +### 3. Run Tests (if applicable) + +```bash +npm test 2>/dev/null +npm run typecheck 2>/dev/null +``` + +### 4. Write Review + +Use the `write` tool to save the review. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-/review.md`). Report the exact path back in your summary. + +**Format:** + +```markdown +# Code Review + +**Reviewed:** [brief description] +**Verdict:** [APPROVED / NEEDS CHANGES] + +## Summary +[1-2 sentence overview] + +## Findings + +### [P0] Critical Issue +**File:** `path/to/file.ts:123` +**Issue:** [description] +**Suggested Fix:** [how to fix] + +### [P1] Important Issue +... + +## What's Good +- [genuine positive observations] +``` + +## Constraints + +- Do NOT modify any code +- DO provide specific, actionable feedback +- DO run tests and report results + +--- + +## Review Rubric + +### Determining What to Flag + +Flag issues that: +1. Meaningfully impact accuracy, performance, security, or maintainability +2. Are discrete and actionable +3. Don't demand rigor inconsistent with the rest of the codebase +4. Were introduced in the changes being reviewed (not pre-existing) +5. The author would likely fix if aware of them +6. Have provable impact (not speculation) + +### Untrusted User Input + +1. Be careful with open redirects — must always check for trusted domains +2. Always flag SQL that is not parametrized +3. User-supplied URL fetches need protection against local resource access (intercept DNS resolver) +4. Escape, don't sanitize if you have the option + +### State Sync / Broadcast Exposure + +When frameworks auto-sync state to clients (e.g. Cloudflare Agents `setState()`, Redux devtools, WebSocket broadcast), check what's in that state. Secrets, answers, API keys, internal IDs — anything the client shouldn't see is a P0 if it's in the broadcast payload. The developer may not realize the framework sends the full object. + +### Review Priorities + +1. Call out newly added dependencies explicitly +2. Prefer simple, direct solutions over unnecessary abstractions +3. Favor fail-fast behavior; avoid logging-and-continue that hides errors +4. Prefer predictable production behavior; crashing > silent degradation +5. Treat back pressure handling as critical +6. Apply system-level thinking; flag operational risk +7. Ensure errors are checked against codes/stable identifiers, never messages + +### Priority Levels — Be Ruthlessly Pragmatic + +The bar for flagging is HIGH. Ask: "Will this actually cause a real problem?" + +- **[P0]** — Drop everything. Will break production, lose data, or create a security hole. Must be provable. **Includes:** leaking secrets/answers to clients, auth bypass, data exposure via auto-sync/broadcast mechanisms. +- **[P1]** — Genuine foot gun. Someone WILL trip over this and waste hours. +- **[P2]** — Worth mentioning. Real improvement, but code works without it. +- **[P3]** — Almost irrelevant. + +### What NOT to Flag + +- Naming preferences (unless actively misleading) +- Hypothetical edge cases (check if they're actually possible first) +- Style differences +- "Best practice" violations where the code works fine +- Speculative future scaling problems + +### What TO Flag + +- Real bugs that will manifest in actual usage +- Security issues with concrete exploit scenarios +- Logic errors where code doesn't match the plan's intent +- Missing error handling where errors WILL occur +- Genuinely confusing code that will cause the next person to introduce bugs + +### Output + +If the code works and is readable, a short review with few findings is the RIGHT answer. Don't manufacture findings. diff --git a/agents/scout.md b/agents/scout.md new file mode 100644 index 0000000..da06174 --- /dev/null +++ b/agents/scout.md @@ -0,0 +1,105 @@ +--- +name: scout +description: Fast codebase reconnaissance - maps existing code, conventions, and patterns for a task +tools: read, bash +deny-tools: claude +model: anthropic/claude-haiku-4-5 +output: context.md +spawning: false +auto-exit: true +system-prompt: append +--- + +# Scout Agent + +You are a **codebase reconnaissance specialist**. You were spawned to quickly explore an existing codebase and gather the context another agent needs to do its work. Lean hard into what's asked, deliver your findings, and exit. + +**You only operate on existing codebases.** Your entire value is reading and understanding what's already there — the files, patterns, conventions, dependencies, and gotchas. If there's no codebase to explore, you have nothing to do. + +--- + +## Principles + +- **Read before you assess** — Actually look at the files. Never assume what code does. +- **Be thorough but fast** — Cover the relevant areas without rabbit holes. Your output feeds other agents. +- **Be direct** — Facts, not fluff. No excessive praise or hedging. +- **Try before asking** — Need to know if a tool or config exists? Just check. + +--- + +## Approach + +1. **Orient** — Understand what the task needs. What are we building, fixing, or changing? +2. **Map the territory** — Find relevant files, modules, entry points, and their relationships. +3. **Read the code** — Don't just list files. Read the important ones. Understand the actual logic. +4. **Surface conventions** — Coding style, naming, project structure, error handling patterns, test patterns. +5. **Flag gotchas** — Anything that could trip up implementation: implicit assumptions, tight coupling, missing validation, undocumented behavior. + +### What to look for + +- **Project structure** — How is the code organized? Monorepo? Flat? Feature-based? +- **Entry points** — Where does execution start? What's the request/data flow? +- **Related code** — What existing code touches the area we're changing? +- **Conventions** — How are similar things done elsewhere in this codebase? +- **Dependencies** — What libraries matter for this task? How are they used? +- **Config & environment** — Build config, env vars, feature flags that affect the area. +- **Tests** — How is this area tested? What patterns do tests follow? + +### Useful commands + +```bash +# Structure +ls -la +find . -type f -name "*.ts" | head -40 +tree -L 2 -I node_modules 2>/dev/null + +# Search +rg "pattern" --type ts -l +rg "functionName" -A 5 -B 2 +rg "import.*from" path/to/file.ts + +# Dependencies & config +cat package.json 2>/dev/null | head -60 +cat tsconfig.json 2>/dev/null +``` + +--- + +## Output + +Use the `write` tool to save your findings. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-/scout-context.md`). Report the exact path back in your summary so downstream agents can read it. + +**Content template:** + +```markdown +# Context for: [task summary] + +## Relevant Files +- `path/to/file.ts` — [what it does, why it matters for this task] + +## Project Structure +[How the codebase is organized — just the parts relevant to the task] + +## Conventions +[Coding style, naming, patterns to follow — based on what you actually read] + +## Dependencies +[Libraries relevant to the task and how they're used] + +## Key Findings +[What you learned that directly affects implementation] + +## Gotchas +[Things that could trip up implementation — coupling, assumptions, edge cases] +``` + +Only include sections that have substance. Skip empty ones. + +--- + +## Constraints + +- **Read-only** — Do NOT modify any files +- **No builds or tests** — Leave that for the worker +- **No implementation decisions** — Leave that for the planner +- **Stay focused** — Only explore what's relevant to the task at hand diff --git a/agents/visual-tester.md b/agents/visual-tester.md new file mode 100644 index 0000000..6ec0db3 --- /dev/null +++ b/agents/visual-tester.md @@ -0,0 +1,198 @@ +--- +name: visual-tester +description: Visual QA tester — navigates web UIs via Chrome CDP, spots visual issues, tests interactions, produces structured reports +tools: bash, read, write +model: anthropic/claude-sonnet-4-6 +skill: chrome-cdp +spawning: false +auto-exit: true +system-prompt: append +--- + +# Visual Tester + +You are a **specialist in an orchestration system**. You were spawned for a specific purpose — test the UI visually, report what's wrong, and exit. Don't fix CSS or rewrite components. Produce a clear report so workers can act on your findings. + +You are a visual QA tester. You use Chrome CDP (`scripts/cdp.mjs`) to control the browser, take screenshots, inspect accessibility trees, interact with elements, and report what looks wrong. + +This is not a formal test suite — it's "let me look at this and check if it's right." + +--- + +## Setup + +### Prerequisites + +- Chrome with remote debugging enabled: `chrome://inspect/#remote-debugging` → toggle the switch +- The target page open in a Chrome tab + +### Getting Started + +```bash +# 1. Find your target tab +scripts/cdp.mjs list + +# 2. Take a screenshot to verify connection +scripts/cdp.mjs shot /tmp/screenshot.png + +# 3. Get the page structure +scripts/cdp.mjs snap +``` + +Use the targetId prefix (e.g. `6BE827FA`) for all commands. Read the **chrome-cdp** skill for the full command reference. + +--- + +## What to Look For + +### Layout & Spacing + +- Elements not aligned, inconsistent padding/margins +- Content touching container edges, overflowing containers +- Unexpected scrollbars + +### Typography + +- Text clipped/truncated, overflowing containers +- Font size hierarchy wrong (h1 smaller than h2) +- Missing or broken web fonts + +### Colors & Contrast + +- Text hard to read against background +- Focus indicators invisible or missing +- Inconsistent color usage + +### Images & Media + +- Broken images, wrong aspect ratios +- Images not responsive + +### Z-index & Overlapping + +- Modals/dropdowns behind other elements +- Fixed headers overlapping content + +### Empty & Edge States + +- No data state, very long/short text, error states, loading states + +--- + +## Responsive Testing + +Test at key breakpoints: + +| Name | Width | Height | +| ------- | ----- | ------ | +| Mobile | 375 | 812 | +| Tablet | 768 | 1024 | +| Desktop | 1280 | 800 | + +```bash +scripts/cdp.mjs evalraw Emulation.setDeviceMetricsOverride '{"width":375,"height":812,"deviceScaleFactor":2,"mobile":true}' +scripts/cdp.mjs shot /tmp/mobile.png +``` + +Reset after: `scripts/cdp.mjs evalraw Emulation.clearDeviceMetricsOverride` + +Use judgment — not every page needs all breakpoints. + +--- + +## Interaction Testing + +```bash +# Click elements +scripts/cdp.mjs click 'button[type="submit"]' +scripts/cdp.mjs shot /tmp/after-click.png + +# Fill forms +scripts/cdp.mjs click 'input[name="email"]' +scripts/cdp.mjs type 'test@example.com' + +# Navigate +scripts/cdp.mjs nav http://localhost:3000/other-page +``` + +**Always screenshot after actions** to verify results. + +--- + +## Dark Mode + +```bash +scripts/cdp.mjs evalraw Emulation.setEmulatedMedia '{"features":[{"name":"prefers-color-scheme","value":"dark"}]}' +scripts/cdp.mjs shot /tmp/dark-mode.png +``` + +Reset: `scripts/cdp.mjs evalraw Emulation.setEmulatedMedia '{"features":[]}'` + +--- + +## Report + +Use the `write` tool to save the report. The orchestrator provides the target path in your task (typically `.pi/plans/YYYY-MM-DD-/visual-test-report.md`). Report the exact path back in your summary. + +**Format:** + +```markdown +# Visual Test Report + +**URL:** http://localhost:3000 +**Viewports tested:** Mobile (375), Desktop (1280) + +## Summary + +Brief overall impression. Ready to ship? + +## Findings + +### P0 — Blockers + +#### [Title] + +- **Location:** Page/component +- **Description:** What's wrong +- **Suggested fix:** How to fix + +### P1 — Major + +... + +### P2 — Minor + +... + +## What's Working Well + +- Positive observations +``` + +| Level | Meaning | Examples | +| ------ | ----------------- | ---------------------------------------- | +| **P0** | Broken / unusable | Button doesn't work, content invisible | +| **P1** | Major visual/UX | Layout broken on mobile, text unreadable | +| **P2** | Cosmetic | Misaligned elements, wrong colors | +| **P3** | Polish | Slightly off margins | + +--- + +## Cleanup + +Before writing the report, restore the browser: + +```bash +scripts/cdp.mjs evalraw Emulation.clearDeviceMetricsOverride +scripts/cdp.mjs evalraw Emulation.setEmulatedMedia '{"features":[]}' +scripts/cdp.mjs nav +``` + +--- + +## Tips + +- **Screenshot liberally.** Before/after for interactions. +- **Use accessibility snapshots** to understand structure. +- **Happy path first.** Basic flow before edge cases. +- **Use common sense.** Not every page needs all breakpoints and dark mode. diff --git a/agents/worker.md b/agents/worker.md new file mode 100644 index 0000000..03b3559 --- /dev/null +++ b/agents/worker.md @@ -0,0 +1,104 @@ +--- +name: worker +description: Implements tasks from todos - writes code, runs tests, commits with polished messages +tools: read, bash, write, edit +deny-tools: claude +model: anthropic/claude-sonnet-4-6 +thinking: minimal +spawning: false +auto-exit: true +system-prompt: append +--- + +# Worker Agent + +You are a **specialist in an orchestration system**. You were spawned for a specific purpose — lean hard into what's asked, deliver, and exit. Don't redesign, don't re-plan, don't expand scope. Trust that scouts gathered context and planners made decisions. Your job is execution. + +You are a senior engineer picking up a well-scoped task. The planning is done — your job is to implement it with quality and care. + +--- + +## Engineering Standards + +### You Own What You Ship +Care about readability, naming, structure. If something feels off, fix it or flag it. + +### Keep It Simple +Write the simplest code that solves the problem. No abstractions for one-time operations, no helpers nobody asked for, no "improvements" beyond scope. + +### Read Before You Edit +Never modify code you haven't read. Understand existing patterns and conventions first. + +### Investigate, Don't Guess +When something breaks, read error messages, form a hypothesis based on evidence. No shotgun debugging. + +### Evidence Before Assertions +Never say "done" without proving it. Run the test, show the output. No "should work." + +--- + +## Workflow + +### 1. Read Your Task + +Everything you need is in the task message: +- What to implement (usually a TODO reference) +- Plan path or context (if provided) +- Acceptance criteria + +If a plan path is mentioned, read it. If a TODO is referenced, read its details: +``` +todo(action: "get", id: "TODO-xxxx") +``` + +### 2. Verify Todo Has Examples & References + +**Before claiming the todo, check that it contains:** +- [ ] A code example or snippet showing expected shape (imports, patterns, structure) +- [ ] OR an explicit reference to existing code to extrapolate from (file path + what to look at) +- [ ] Explicit constraints (libraries to use, patterns to follow, anti-patterns to avoid) + +**If any of these are missing, STOP and report back.** Do NOT guess or improvise. Write a clear message explaining what's missing: + +> "TODO-xxxx is missing [examples / references / constraints]. I need: +> - [specific thing 1: e.g., 'a code example showing how to structure the Effect service'] +> - [specific thing 2: e.g., 'which existing file to use as a reference for the component pattern'] +> +> Cannot implement without this context." + +Then **release the todo** and exit. The orchestrator will provide the missing context and re-assign. + +This is not a failure — it's quality control. Guessing leads to building the wrong thing. Asking leads to building the right thing. + +### 3. Claim the Todo + +``` +todo(action: "claim", id: "TODO-xxxx") +``` + +### 4. Implement + +- Follow existing patterns — your code should look like it belongs +- Keep changes minimal and focused +- Test as you go + +### 5. Verify + +Before marking done: +- Run tests or verify the feature works +- Check for regressions +- **For integration/framework changes** (new hooks, decorators, state management, API changes): start the dev server and hit the actual endpoint or load the page. Type errors pass `vp check` but runtime crashes (missing bindings, framework initialization order, RPC serialization) only surface when you run it. +- **Check against ISC if provided** — if the plan includes Ideal State Criteria, verify your work against each relevant ISC item. Mark them with evidence (command output, file path, test result). "Should work" is not evidence. + +### 6. Commit + +Load the commit skill and make a polished, descriptive commit: +``` +/skill:commit +``` + +### 7. Close the Todo + +``` +todo(action: "update", id: "TODO-xxxx", status: "closed") +``` diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..9c8db04 --- /dev/null +++ b/config.json.example @@ -0,0 +1,5 @@ +{ + "status": { + "enabled": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..444020c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3809 @@ +{ + "name": "pi-interactive-subagents", + "version": "1.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-interactive-subagents", + "version": "1.6.0", + "license": "MIT", + "devDependencies": { + "@mariozechner/pi-coding-agent": "^0.65.0", + "@mariozechner/pi-tui": "^0.65.0", + "@sinclair/typebox": "^0.34.49" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-tui": "*", + "@sinclair/typebox": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1024.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1024.0.tgz", + "integrity": "sha512-nIhsn0/eYrL2fTh4kMO7Hpfmhv+AkkXl0KGNpD6+fdmotGvRBWcDv9/PmP/+sT6gvrKTYyzH3vu4efpTPzzP0Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/eventstream-handler-node": "^3.972.12", + "@aws-sdk/middleware-eventstream": "^3.972.8", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/middleware-websocket": "^3.972.14", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/token-providers": "3.1024.0", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", + "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.16", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", + "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", + "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", + "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-login": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", + "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", + "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", + "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", + "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/token-providers": "3.1021.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", + "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", + "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.12.tgz", + "integrity": "sha512-ruyc/MNR6e+cUrGCth7fLQ12RXBZDy/bV06tgqB9Z5n/0SN/C0m6bsQEV8FF9zPI6VSAOaRd0rNgmpYVnGawrQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.8.tgz", + "integrity": "sha512-r+oP+tbCxgqXVC3pu3MUVePgSY0ILMjA+aEwOosS77m3/DRbtvHrHwqvMcw+cjANMeGzJ+i0ar+n77KXpRA8RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", + "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", + "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.14.tgz", + "integrity": "sha512-qnfDlIHjm6DrTYNvWOUbnZdVKgtoKbO/Qzj+C0Wp5Y7VUrsvBRQtGKxD+hc+mRTS4N0kBJ6iZ3+zxm4N1OSyjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-format-url": "^3.972.8", + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", + "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.14", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", + "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1024.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1024.0.tgz", + "integrity": "sha512-eoyTMgd6OzoE1dq50um5Y53NrosEkWsjH0W6pswi7vrv1W9hY/7hR43jDcPevqqj+OQksf/5lc++FTqRlb8Y1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", + "integrity": "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", + "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@google/genai": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz", + "integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz", + "integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.2", + "@mariozechner/clipboard-darwin-universal": "0.3.2", + "@mariozechner/clipboard-darwin-x64": "0.3.2", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", + "@mariozechner/clipboard-linux-x64-musl": "0.3.2", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz", + "integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz", + "integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz", + "integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/jiti": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", + "integrity": "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "std-env": "^3.10.0", + "yoctocolors": "^2.1.2" + }, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@mariozechner/pi-agent-core": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.65.0.tgz", + "integrity": "sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mariozechner/pi-ai": "^0.65.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-ai": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.65.0.tgz", + "integrity": "sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", + "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "1.14.1", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "proxy-agent": "^6.5.0", + "undici": "^7.19.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@mariozechner/pi-coding-agent": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.65.0.tgz", + "integrity": "sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mariozechner/jiti": "^2.6.2", + "@mariozechner/pi-agent-core": "^0.65.0", + "@mariozechner/pi-ai": "^0.65.0", + "@mariozechner/pi-tui": "^0.65.0", + "@silvia-odwyer/photon-node": "^0.3.4", + "ajv": "^8.17.1", + "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", + "diff": "^8.0.2", + "extract-zip": "^2.0.1", + "file-type": "^21.1.1", + "glob": "^13.0.1", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "marked": "^15.0.12", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "strip-ansi": "^7.1.0", + "undici": "^7.19.1", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.2" + } + }, + "node_modules/@mariozechner/pi-tui": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.65.0.tgz", + "integrity": "sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", + "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "dev": true, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.1" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.13", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", + "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.28", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", + "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", + "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.13", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", + "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", + "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", + "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.13", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", + "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", + "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", + "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.21", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", + "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/koffi": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.4.tgz", + "integrity": "sha512-6l7xxt8heHWQ63WyGd8ofne4TrzhqeKHhvSlI3GnxMIHp3PlDrOPyZbW5YNINXNma1qrKkpM/PGLY8U0V8Hxbw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", + "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c4a4a8 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "pi-interactive-subagents", + "version": "3.7.2", + "description": "Interactive subagents for pi - spawn, orchestrate, and manage sub-agent sessions in cmux/tmux/zellij terminals", + "keywords": [ + "pi-package" + ], + "license": "MIT", + "author": "HazAT", + "repository": { + "type": "git", + "url": "https://github.com/HazAT/pi-interactive-subagents" + }, + "type": "module", + "scripts": { + "test": "node --test test/test.ts", + "test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-tui": "*", + "@sinclair/typebox": "*" + }, + "pi": { + "extensions": [ + "./pi-extension/subagents/index.ts" + ] + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "^0.65.0", + "@mariozechner/pi-tui": "^0.65.0", + "@sinclair/typebox": "^0.34.49" + } +} diff --git a/pi-extension/subagents/activity.ts b/pi-extension/subagents/activity.ts new file mode 100644 index 0000000..6e93c58 --- /dev/null +++ b/pi-extension/subagents/activity.ts @@ -0,0 +1,511 @@ +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" + | "caller_ping" + | "subagent_done" + | "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; + callerPing(): void; + subagentDone(): void; + sessionShutdown(reason: SubagentShutdownReason): void; +} + +const ACTIVITY_UPDATE_THROTTLE_MS = 500; +const MAX_WRITE_FAILURES = 3; +const KNOWN_PHASES = new Set(["starting", "active", "waiting", "done"]); +const KNOWN_SCOPES = new Set(["agent", "turn", "provider", "streaming", "tool"]); +const KNOWN_EVENTS = new Set([ + "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", + "caller_ping", + "subagent_done", + "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 | null { + if (value == null || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function validateFiniteNumber(object: Record, fieldName: string): string | null { + return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`; +} + +function validateOptionalFiniteNumber(object: Record, 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, fieldName: string): string | null { + return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`; +} + +function validateOptionalInteger(object: Record, 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, fieldName: string): string | null { + return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`; +} + +function validateOptionalActivityString(object: Record, 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() {}, + callerPing() {}, + subagentDone() {}, + 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 runningChildId = params.runningChildId?.trim(); + const activityFile = params.activityFile?.trim(); + if (!runningChildId || !activityFile) return createNoopRecorder(); + + 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 | 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"); + }, + callerPing() { + markDone("caller_ping"); + }, + subagentDone() { + markDone("subagent_done"); + }, + sessionShutdown(reason) { + if (reason === "quit") markDone("session_shutdown"); + else disable(); + }, + }; +} diff --git a/pi-extension/subagents/cmux.ts b/pi-extension/subagents/cmux.ts new file mode 100644 index 0000000..979684b --- /dev/null +++ b/pi-extension/subagents/cmux.ts @@ -0,0 +1,1334 @@ +import { execSync, execFile, execFileSync, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +const execFileAsync = promisify(execFile); + +export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm"; + +const commandAvailability = new Map(); + +function hasCommand(command: string): boolean { + if (commandAvailability.has(command)) { + return commandAvailability.get(command)!; + } + + let available = false; + if (process.platform === "win32") { + try { + execFileSync("where.exe", [command], { stdio: "ignore" }); + available = true; + } catch { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + } + } else { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + available = true; + } catch { + available = false; + } + } + + commandAvailability.set(command, available); + return available; +} + +function muxPreference(): MuxBackend | null { + const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase(); + if (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm") return pref; + return null; +} + +function isCmuxRuntimeAvailable(): boolean { + return !!process.env.CMUX_SOCKET_PATH && hasCommand("cmux"); +} + +function isTmuxRuntimeAvailable(): boolean { + return !!process.env.TMUX && hasCommand("tmux"); +} + +function isZellijRuntimeAvailable(): boolean { + return !!(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) && hasCommand("zellij"); +} + +function isWezTermRuntimeAvailable(): boolean { + return !!process.env.WEZTERM_UNIX_SOCKET && hasCommand("wezterm"); +} + +export function isCmuxAvailable(): boolean { + return isCmuxRuntimeAvailable(); +} + +export function isTmuxAvailable(): boolean { + return isTmuxRuntimeAvailable(); +} + +export function isZellijAvailable(): boolean { + return isZellijRuntimeAvailable(); +} + +export function isWezTermAvailable(): boolean { + return isWezTermRuntimeAvailable(); +} + +export function getMuxBackend(): MuxBackend | null { + const pref = muxPreference(); + if (pref === "cmux") return isCmuxRuntimeAvailable() ? "cmux" : null; + if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null; + if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null; + if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null; + + if (isCmuxRuntimeAvailable()) return "cmux"; + if (isTmuxRuntimeAvailable()) return "tmux"; + if (isZellijRuntimeAvailable()) return "zellij"; + if (isWezTermRuntimeAvailable()) return "wezterm"; + return null; +} + +export function isMuxAvailable(): boolean { + return getMuxBackend() !== null; +} + +export function muxSetupHint(): string { + const pref = muxPreference(); + if (pref === "cmux") { + return "Start pi inside cmux (`cmux pi`)."; + } + if (pref === "tmux") { + return "Start pi inside tmux (`tmux new -A -s pi 'pi'`)."; + } + if (pref === "zellij") { + return "Start pi inside zellij (`zellij --session pi`, then run `pi`)."; + } + if (pref === "wezterm") { + return "Start pi inside WezTerm."; + } + return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), or WezTerm."; +} + +function requireMuxBackend(): MuxBackend { + const backend = getMuxBackend(); + if (!backend) { + throw new Error(`No supported terminal multiplexer found. ${muxSetupHint()}`); + } + return backend; +} + +/** + * Detect if the user's default shell is fish. + * Fish uses $status instead of $? for exit codes. + */ +export function isFishShell(): boolean { + const shell = process.env.SHELL ?? ""; + return basename(shell) === "fish"; +} + +/** + * Return the shell-appropriate exit status variable ($? for bash/zsh, $status for fish). + */ +export function exitStatusVar(): string { + return isFishShell() ? "$status" : "$?"; +} + +export function shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function tailLines(text: string, lines: number): string { + const split = text.split("\n"); + if (split.length <= lines) return text; + return split.slice(-lines).join("\n"); +} + +function zellijPaneId(surface: string): string { + return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface; +} + +function zellijEnv(surface?: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (surface) { + env.ZELLIJ_PANE_ID = zellijPaneId(surface); + } + return env; +} + +/** + * Pane-scoped zellij actions that must target a specific pane via --pane-id + * (the ZELLIJ_PANE_ID env var is ignored by most of these). + * See https://github.com/HazAT/pi-interactive-subagents/issues/19 + */ +const ZELLIJ_PANE_SCOPED_ACTIONS = new Set([ + "close-pane", + "dump-screen", + "rename-pane", + "move-pane", + "write", + "write-chars", + "send-keys", +]); + +function zellijActionArgs(args: string[], surface?: string): string[] { + if (!surface) return ["action", ...args]; + const action = args[0]; + if (!ZELLIJ_PANE_SCOPED_ACTIONS.has(action)) return ["action", ...args]; + // Don't double-add if caller already specified it. + if (args.includes("--pane-id") || args.includes("-p")) return ["action", ...args]; + return ["action", action, "--pane-id", zellijPaneId(surface), ...args.slice(1)]; +} + +function zellijActionSync(args: string[], surface?: string): string { + return execFileSync("zellij", zellijActionArgs(args, surface), { + encoding: "utf8", + env: zellijEnv(surface), + }); +} + +async function zellijActionAsync(args: string[], surface?: string): Promise { + const { stdout } = await execFileAsync("zellij", zellijActionArgs(args, surface), { + encoding: "utf8", + env: zellijEnv(surface), + }); + return stdout; +} + +/** Tracked subagent pane for cmux — reused across subagent launches. */ +let cmuxSubagentPane: string | null = null; + +// Mirrors Zellij 0.44.x tab minimums, used to predict which pane Zellij itself +// will choose for a directionless split. +const ZELLIJ_MIN_TERMINAL_WIDTH = 5; +const ZELLIJ_MIN_TERMINAL_HEIGHT = 5; +const ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO = 4; + +// Pi subagents need more usable space than Zellij's internal minimum. These can +// be tuned per session without another code change. +const DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS = 50; +const DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS = 10; + +export interface ZellijPaneSnapshot { + id: number; + is_plugin?: boolean; + is_floating?: boolean; + is_selectable?: boolean; + exited?: boolean; + pane_rows?: number; + pane_columns?: number; + tab_id?: number; + is_focused?: boolean; +} + +export type ZellijSplitDirection = "down" | "right"; + +export type ZellijPlacementPlan = + | { + mode: "split"; + anchorPaneId: number; + targetPaneId: number; + tabId: number; + splitDirection: ZellijSplitDirection; + } + | { mode: "stack"; anchorPaneId: number; targetPaneId: number; tabId: number }; + +function paneArea(pane: ZellijPaneSnapshot): number { + return (pane.pane_rows ?? 0) * (pane.pane_columns ?? 0); +} + +function isUsableZellijTiledPane(pane: ZellijPaneSnapshot): boolean { + return ( + !pane.is_plugin && + !pane.is_floating && + pane.is_selectable !== false && + !pane.exited && + typeof pane.pane_rows === "number" && + typeof pane.pane_columns === "number" + ); +} + +export function predictZellijSplitDirection(pane: ZellijPaneSnapshot): ZellijSplitDirection | null { + const columns = pane.pane_columns ?? 0; + const rows = pane.pane_rows ?? 0; + if (columns < ZELLIJ_MIN_TERMINAL_WIDTH || rows < ZELLIJ_MIN_TERMINAL_HEIGHT) return null; + + if ( + rows * ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO > columns && + rows > ZELLIJ_MIN_TERMINAL_HEIGHT * 2 + ) { + return "down"; + } + + if (columns > ZELLIJ_MIN_TERMINAL_WIDTH * 2) { + return "right"; + } + + return null; +} + +export function canSplitZellijPane( + pane: ZellijPaneSnapshot, + minColumns = ZELLIJ_MIN_TERMINAL_WIDTH, + minRows = ZELLIJ_MIN_TERMINAL_HEIGHT, +): boolean { + const columns = pane.pane_columns ?? 0; + const rows = pane.pane_rows ?? 0; + const direction = predictZellijSplitDirection(pane); + if (!direction) return false; + + if (direction === "down") { + return columns >= minColumns && Math.floor(rows / 2) >= minRows; + } + + return rows >= minRows && Math.floor(columns / 2) >= minColumns; +} + +function zellijTabPanesForParent( + panes: ZellijPaneSnapshot[], + parentPaneId: number, +): { parentPane: ZellijPaneSnapshot; tabPanes: ZellijPaneSnapshot[] } | null { + const parentPane = panes.find((pane) => !pane.is_plugin && pane.id === parentPaneId); + if (!parentPane || typeof parentPane.tab_id !== "number") return null; + + const tabPanes = panes + .filter((pane) => pane.tab_id === parentPane.tab_id) + .filter(isUsableZellijTiledPane); + + return { parentPane, tabPanes }; +} + +export function selectZellijStackPlacement( + panes: ZellijPaneSnapshot[], + parentPaneId: number, +): ZellijPlacementPlan | null { + const tabInfo = zellijTabPanesForParent(panes, parentPaneId); + if (!tabInfo) return null; + + const stackTarget = tabInfo.tabPanes + .filter((pane) => pane.id !== parentPaneId) + .sort((a, b) => paneArea(b) - paneArea(a))[0]; + if (!stackTarget) return null; + + return { + mode: "stack", + anchorPaneId: stackTarget.id, + targetPaneId: stackTarget.id, + tabId: tabInfo.parentPane.tab_id!, + }; +} + +export function selectZellijPlacement( + panes: ZellijPaneSnapshot[], + parentPaneId: number, + minColumns = DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS, + minRows = DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS, +): ZellijPlacementPlan | null { + const tabInfo = zellijTabPanesForParent(panes, parentPaneId); + if (!tabInfo) return null; + + const zellijSplitCandidates = tabInfo.tabPanes + .map((pane) => ({ pane, splitDirection: predictZellijSplitDirection(pane) })) + .filter( + (candidate): candidate is { pane: ZellijPaneSnapshot; splitDirection: ZellijSplitDirection } => + candidate.splitDirection !== null && + canSplitZellijPane(candidate.pane, ZELLIJ_MIN_TERMINAL_WIDTH, ZELLIJ_MIN_TERMINAL_HEIGHT), + ); + + const safeSplitCandidates = zellijSplitCandidates.filter((candidate) => + canSplitZellijPane(candidate.pane, minColumns, minRows), + ); + + // Split creation is tab-scoped, so Zellij chooses the concrete split pane. + // Only split when every pane Zellij might split would remain usable. + if ( + zellijSplitCandidates.length > 0 && + safeSplitCandidates.length === zellijSplitCandidates.length + ) { + const splitTarget = safeSplitCandidates.sort((a, b) => paneArea(b.pane) - paneArea(a.pane))[0]; + return { + mode: "split", + anchorPaneId: splitTarget.pane.id, + targetPaneId: splitTarget.pane.id, + tabId: tabInfo.parentPane.tab_id!, + splitDirection: splitTarget.splitDirection, + }; + } + + return selectZellijStackPlacement(panes, parentPaneId); +} + +function parseZellijPaneSurface(rawId: string, context: string): string { + const idMatch = rawId.match(/(\d+)/); + if (!idMatch) { + throw new Error(`Unexpected zellij pane id from ${context}: ${rawId || "(empty)"}`); + } + return `pane:${idMatch[1]}`; +} + +function readZellijPanes(): ZellijPaneSnapshot[] { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const output = zellijActionSync(["list-panes", "--json", "--geometry", "--state", "--tab"]); + if (!output.trim()) { + throw new Error("Unexpected zellij list-panes output: empty"); + } + const parsed = JSON.parse(output); + if (!Array.isArray(parsed)) { + throw new Error("Unexpected zellij list-panes output: not an array"); + } + return parsed as ZellijPaneSnapshot[]; + } catch (error) { + lastError = error; + if (attempt < 2) sleepSync(50); + } + } + throw lastError; +} + +function createZellijTiledPane(name: string, tabId: number): string { + const args = ["new-pane", "--tab-id", String(tabId), "--name", name, "--cwd", process.cwd()]; + return parseZellijPaneSurface(zellijActionSync(args).trim(), "new-pane"); +} + +function createZellijStackedPane(name: string, anchorSurface: string): string { + const args = [ + "new-pane", + "--stacked", + "--near-current-pane", + "--name", + name, + "--cwd", + process.cwd(), + ]; + return parseZellijPaneSurface(zellijActionSync(args, anchorSurface).trim(), "new-pane --stacked"); +} + +function createZellijTab(name: string): string { + const tabIdRaw = zellijActionSync(["new-tab", "--name", name, "--cwd", process.cwd()]).trim(); + const tabId = Number(tabIdRaw); + if (!Number.isInteger(tabId)) { + throw new Error(`Unexpected zellij tab id from new-tab: ${tabIdRaw || "(empty)"}`); + } + + try { + const panes = readZellijPanes(); + const pane = panes.find( + (candidate) => + candidate.tab_id === tabId && + isUsableZellijTiledPane(candidate) && + typeof candidate.id === "number", + ); + if (!pane) { + throw new Error(`Could not find initial pane for zellij tab ${tabId}`); + } + + const surface = `pane:${pane.id}`; + try { + zellijActionSync(["rename-pane", name], surface); + } catch { + // Optional. + } + return surface; + } catch (error) { + try { + zellijActionSync(["close-tab", "--tab-id", String(tabId)]); + } catch { + // Best effort cleanup for tabs created before post-creation inspection failed. + } + throw error; + } +} + +function envPositiveInteger(name: string, fallback: number): number { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function sleepSync(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function zellijSurfaceLockPath(): string { + const session = (process.env.ZELLIJ_SESSION_NAME ?? process.env.ZELLIJ ?? "default").replace( + /[^A-Za-z0-9_.-]/g, + "_", + ); + return join(tmpdir(), `pi-zellij-surface-${session}.lock`); +} + +function withZellijSurfaceLock(callback: () => T): T { + const lockPath = zellijSurfaceLockPath(); + const deadline = Date.now() + 10000; + + while (true) { + try { + mkdirSync(lockPath); + writeFileSync(join(lockPath, "owner"), `${process.pid}\n`); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + + try { + if (Date.now() - statSync(lockPath).mtimeMs > 30000) { + rmSync(lockPath, { recursive: true, force: true }); + continue; + } + } catch {} + + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for zellij surface lock: ${lockPath}`); + } + sleepSync(50); + } + } + + try { + return callback(); + } finally { + rmSync(lockPath, { recursive: true, force: true }); + } +} + +function createZellijSurfaceUnlocked(name: string): string { + const parentPaneIdRaw = process.env.ZELLIJ_PANE_ID; + const parentPaneId = parentPaneIdRaw ? Number(parentPaneIdRaw) : NaN; + const minColumns = envPositiveInteger( + "PI_SUBAGENT_ZELLIJ_MIN_COLUMNS", + DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS, + ); + const minRows = envPositiveInteger( + "PI_SUBAGENT_ZELLIJ_MIN_ROWS", + DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS, + ); + + const plan = Number.isInteger(parentPaneId) + ? selectZellijPlacement(readZellijPanes(), parentPaneId, minColumns, minRows) + : null; + + if (plan?.mode === "split") { + return createZellijTiledPane(name, plan.tabId); + } + + if (plan?.mode === "stack") { + return createZellijStackedPane(name, `pane:${plan.targetPaneId}`); + } + + return createZellijTab(name); +} + +function createZellijSurface(name: string): string { + return withZellijSurfaceLock(() => createZellijSurfaceUnlocked(name)); +} + +type CmuxFocusSnapshot = { + surfaceRef?: string; + paneRef?: string; +}; + +type CmuxCreatedSurface = { + surface: string; + paneRef?: string; +}; + +type CmuxIdentifySnapshot = { + focused: CmuxFocusSnapshot | null; + caller: CmuxFocusSnapshot | null; +}; + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +export function parseCmuxFocusedSnapshot(value: unknown): CmuxFocusSnapshot | null { + if (!value || typeof value !== "object") return null; + + const focused = (value as { focused?: unknown }).focused; + if (!focused || typeof focused !== "object") return null; + + const record = focused as { surface_ref?: unknown; pane_ref?: unknown }; + const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined; + const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined; + + if (!surfaceRef && !paneRef) return null; + return { surfaceRef, paneRef }; +} + +export function parseCmuxJson(value: string): unknown | null { + try { + return JSON.parse(value); + } catch (error) { + void error; + return null; + } +} + +export function parseCmuxFocusedSnapshotFromJson(value: string): CmuxFocusSnapshot | null { + return parseCmuxFocusedSnapshot(parseCmuxJson(value)); +} + +function parseCmuxCallerSnapshot(value: unknown): CmuxFocusSnapshot | null { + if (!value || typeof value !== "object") return null; + + const caller = (value as { caller?: unknown }).caller; + if (!caller || typeof caller !== "object") return null; + + const record = caller as { surface_ref?: unknown; pane_ref?: unknown }; + const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined; + const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined; + + if (!surfaceRef && !paneRef) return null; + return { surfaceRef, paneRef }; +} + +export function parseCmuxPaneRefForSurface(value: unknown, surface: string): string | null { + if (!value || typeof value !== "object") return null; + + const record = value as { surface_ref?: unknown; pane_ref?: unknown; caller?: unknown }; + if (record.surface_ref === surface && nonEmptyString(record.pane_ref)) return record.pane_ref; + + const caller = record.caller; + if (!caller || typeof caller !== "object") return null; + + const callerRecord = caller as { surface_ref?: unknown; pane_ref?: unknown }; + if (callerRecord.surface_ref === surface && nonEmptyString(callerRecord.pane_ref)) { + return callerRecord.pane_ref; + } + + return null; +} + +export function parseCmuxPaneRefForSurfaceFromJson(value: string, surface: string): string | null { + return parseCmuxPaneRefForSurface(parseCmuxJson(value), surface); +} + +function readCmux(args: string[]): string | null { + const result = spawnSync("cmux", args, { encoding: "utf8" }); + if (result.error || result.status !== 0 || !result.stdout.trim()) return null; + return result.stdout; +} + +function parseCmuxIdentifySnapshot(value: string | null): CmuxIdentifySnapshot { + const parsed = value ? parseCmuxJson(value) : null; + return { + focused: parseCmuxFocusedSnapshot(parsed), + caller: parseCmuxCallerSnapshot(parsed), + }; +} + +function captureCmuxIdentifySnapshot(): CmuxIdentifySnapshot { + return parseCmuxIdentifySnapshot(readCmux(["identify", "--json"])); +} + +function captureCmuxFocusSnapshot(): CmuxFocusSnapshot | null { + return captureCmuxIdentifySnapshot().focused; +} + +function readCmuxPaneRefForSurface(surface: string): string | null { + const info = readCmux(["identify", "--surface", surface]); + return info ? parseCmuxPaneRefForSurfaceFromJson(info, surface) : null; +} + +function restoreCmuxFocusSnapshot(snapshot: CmuxFocusSnapshot | null): void { + if (!snapshot) return; + + if (snapshot.paneRef) { + spawnSync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" }); + } + + if (snapshot.surfaceRef) { + spawnSync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" }); + } +} + +function waitForCmuxFocusSettle(): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); +} + +function cmuxFocusMatchesChild( + currentFocus: CmuxFocusSnapshot | null, + child: CmuxCreatedSurface, +): boolean { + if (!currentFocus) return false; + if (currentFocus.surfaceRef === child.surface) return true; + return !!currentFocus.paneRef && currentFocus.paneRef === child.paneRef; +} + +function cmuxFocusMatchesSurfaceRef( + currentFocus: CmuxFocusSnapshot | null, + surfaceRef: string | undefined, +): boolean { + return !!surfaceRef && currentFocus?.surfaceRef === surfaceRef; +} + +function cmuxFocusMatchesPaneRef( + currentFocus: CmuxFocusSnapshot | null, + paneRef: string | undefined, +): boolean { + return !!paneRef && currentFocus?.paneRef === paneRef; +} + +function restoreCmuxFocusIfLaunchSurfaceFocused( + snapshot: CmuxFocusSnapshot | null, + child: CmuxCreatedSurface, + options?: { sourceSurfaceRef?: string; callerSnapshot?: CmuxFocusSnapshot | null }, +): void { + if (!snapshot) return; + + waitForCmuxFocusSettle(); + const currentFocus = captureCmuxFocusSnapshot(); + if ( + cmuxFocusMatchesChild(currentFocus, child) || + cmuxFocusMatchesSurfaceRef(currentFocus, options?.sourceSurfaceRef) || + cmuxFocusMatchesSurfaceRef(currentFocus, options?.callerSnapshot?.surfaceRef) || + // cmux can settle focus onto another active surface in the caller pane after creating a split/surface. + cmuxFocusMatchesPaneRef(currentFocus, options?.callerSnapshot?.paneRef) + ) { + restoreCmuxFocusSnapshot(snapshot); + } +} + +function parseCmuxCreatedSurface(output: string, command: string): CmuxCreatedSurface { + const surfaceMatch = output.match(/surface:\d+/); + if (!surfaceMatch) { + throw new Error(`Unexpected cmux ${command} output: ${output}`); + } + + return { + surface: surfaceMatch[0], + paneRef: output.match(/pane:\d+/)?.[0], + }; +} + +function renameCmuxSurface(surface: string, name: string): void { + execFileSync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" }); +} + +function createCmuxSplitSurface( + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): CmuxCreatedSurface { + const identifySnapshot = captureCmuxIdentifySnapshot(); + const focusSnapshot = identifySnapshot.focused; + const callerSnapshot = identifySnapshot.caller; + let child: CmuxCreatedSurface | null = null; + + try { + const args = ["new-split", direction]; + if (fromSurface) args.push("--surface", fromSurface); + + const output = execFileSync("cmux", args, { encoding: "utf8" }).trim(); + child = parseCmuxCreatedSurface(output, "new-split"); + child.paneRef ??= readCmuxPaneRefForSurface(child.surface) ?? undefined; + renameCmuxSurface(child.surface, name); + return child; + } finally { + if (child) { + restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, { + sourceSurfaceRef: fromSurface, + callerSnapshot, + }); + } else { + restoreCmuxFocusSnapshot(focusSnapshot); + } + } +} + +/** + * Create a new terminal surface for a subagent. + * + * For cmux: the first call creates a right-split pane; subsequent calls add + * tabs to that same pane (avoiding ever-narrower splits). + * For zellij: chooses a tab-aware tiled or stacked placement. + * For tmux/wezterm: falls back to split behavior. + * + * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm). + */ +export function createSurface(name: string): string { + const backend = getMuxBackend(); + + if (backend === "cmux" && cmuxSubagentPane) { + // Verify the pane still exists before adding a tab to it + try { + const tree = execSync(`cmux tree`, { encoding: "utf8" }); + if (tree.includes(cmuxSubagentPane)) { + return createSurfaceInPane(name, cmuxSubagentPane); + } + } catch {} + // Pane is gone — fall through to create a new split + cmuxSubagentPane = null; + } + + if (backend === "cmux") { + const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID); + cmuxSubagentPane = created.paneRef ?? null; + return created.surface; + } + + if (backend === "zellij") { + return createZellijSurface(name); + } + + // On tmux, target the parent pi's pane so splits follow the agent, not the user's focus. + // See https://github.com/HazAT/pi-interactive-subagents/issues/12 + const fromSurface = backend === "tmux" ? process.env.TMUX_PANE : undefined; + return createSurfaceSplit(name, "right", fromSurface); +} + +/** + * Create a new surface (tab) in an existing cmux pane. + */ +function createSurfaceInPane(name: string, pane: string): string { + const identifySnapshot = captureCmuxIdentifySnapshot(); + const focusSnapshot = identifySnapshot.focused; + const callerSnapshot = identifySnapshot.caller; + let child: CmuxCreatedSurface | null = null; + + try { + const output = execFileSync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" }).trim(); + child = parseCmuxCreatedSurface(output, "new-surface"); + child.paneRef ??= pane; + renameCmuxSurface(child.surface, name); + return child.surface; + } finally { + if (child) { + restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, { + callerSnapshot, + }); + } else { + restoreCmuxFocusSnapshot(focusSnapshot); + } + } +} + +/** + * Create a new split in the given direction from an optional source pane. + * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm). + */ +export function createSurfaceSplit( + name: string, + direction: "left" | "right" | "up" | "down", + fromSurface?: string, +): string { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + return createCmuxSplitSurface(name, direction, fromSurface).surface; + } + + if (backend === "tmux") { + const args = ["split-window", "-d"]; + if (direction === "left" || direction === "right") { + args.push("-h"); + } else { + args.push("-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}`); + } + + return pane; + } + + if (backend === "wezterm") { + const args = ["cli", "split-pane"]; + if (direction === "left") args.push("--left"); + else if (direction === "right") args.push("--right"); + else if (direction === "up") args.push("--top"); + else args.push("--bottom"); + args.push("--cwd", process.cwd()); + if (fromSurface) { + args.push("--pane-id", fromSurface); + } + const paneId = execFileSync("wezterm", args, { encoding: "utf8" }).trim(); + if (!paneId || !/^\d+$/.test(paneId)) { + throw new Error(`Unexpected wezterm split-pane output: ${paneId || "(empty)"}`); + } + try { + execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], { + encoding: "utf8", + }); + } catch { + // Optional — tab title is cosmetic. + } + return paneId; + } + + // zellij + const directionArg = direction === "left" || direction === "right" ? "right" : "down"; + const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()]; + + let rawId: string; + try { + rawId = zellijActionSync(args, fromSurface).trim(); + } catch { + if (!fromSurface) throw new Error("Failed to create zellij pane"); + rawId = zellijActionSync(args).trim(); + } + + // zellij returns the pane ID as e.g. "terminal_7" — extract the numeric part. + // Previously we sent `write-chars "echo $ZELLIJ_PANE_ID"` to a temp file, but + // `write-chars` without --pane-id targets the focused pane, which raced on tab switches. + const surface = parseZellijPaneSurface(rawId, "new-pane"); + + if (direction === "left" || direction === "up") { + try { + zellijActionSync(["move-pane", direction], surface); + } catch { + // Optional layout polish. + } + } + + try { + zellijActionSync(["rename-pane", name], surface); + } catch { + // Optional. + } + + return surface; +} + +/** + * Rename the current tab/window. + */ +export function renameCurrentTab(title: string): void { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + const surfaceId = process.env.CMUX_SURFACE_ID; + if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set"); + execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, { + encoding: "utf8", + }); + return; + } + + if (backend === "tmux") { + if (process.env.PI_SUBAGENT_RENAME_TMUX_WINDOW !== "1") { + return; + } + const paneId = process.env.TMUX_PANE; + if (!paneId) throw new Error("TMUX_PANE not set"); + const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], { + encoding: "utf8", + }).trim(); + execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" }); + return; + } + + if (backend === "wezterm") { + const paneId = process.env.WEZTERM_PANE; + const args = ["cli", "set-tab-title"]; + if (paneId) args.push("--pane-id", paneId); + args.push(title); + execFileSync("wezterm", args, { encoding: "utf8" }); + return; + } + + // zellij: rename the agent's own pane, not the whole tab. In multi-pane layouts, + // rename-tab clobbers the user's tab title whenever a subagent starts or /plan runs. + // Closes #21. + const paneId = process.env.ZELLIJ_PANE_ID; + if (paneId) { + zellijActionSync(["rename-pane", title], `pane:${paneId}`); + } else { + zellijActionSync(["rename-pane", title]); + } +} + +/** + * Rename the current workspace/session where supported. + */ +export function renameWorkspace(title: string): void { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, { + encoding: "utf8", + }); + return; + } + + if (backend === "tmux") { + if (process.env.PI_SUBAGENT_RENAME_TMUX_SESSION !== "1") { + return; + } + + const paneId = process.env.TMUX_PANE; + if (!paneId) throw new Error("TMUX_PANE not set"); + const sessionId = execFileSync( + "tmux", + ["display-message", "-p", "-t", paneId, "#{session_id}"], + { + encoding: "utf8", + }, + ).trim(); + execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" }); + return; + } + + if (backend === "wezterm") { + const paneId = process.env.WEZTERM_PANE; + const args = ["cli", "set-window-title"]; + if (paneId) args.push("--pane-id", paneId); + args.push(title); + try { + execFileSync("wezterm", args, { encoding: "utf8" }); + } catch { + // Optional — window title is cosmetic. + } + return; + } + + // Skip session rename for zellij. rename-session renames the socket file + // but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old + // name, so all subsequent `zellij action ...` CLI calls fail with + // "There is no active session!" because the CLI can't find the socket. + // Additionally, pi titles often contain special characters (em dashes, + // spaces) that fail zellij's session name validation on lookup. + // rename-tab (called separately) is sufficient for user-visible naming. +} + +/** + * Send a command string to a pane and execute it. + */ +export function sendCommand(surface: string, command: string): void { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, { + encoding: "utf8", + }); + return; + } + + if (backend === "tmux") { + execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" }); + execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" }); + return; + } + + if (backend === "wezterm") { + execFileSync( + "wezterm", + ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], + { encoding: "utf8" }, + ); + return; + } + + zellijActionSync(["write-chars", command], surface); + zellijActionSync(["write", "13"], surface); +} + +/** + * Send one Escape keypress to an active pane. + */ +export function sendEscape(surface: string): void { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + execFileSync("cmux", ["send", "--surface", surface, "\u001b"], { encoding: "utf8" }); + return; + } + + if (backend === "tmux") { + execFileSync("tmux", ["send-keys", "-t", surface, "Escape"], { encoding: "utf8" }); + return; + } + + if (backend === "wezterm") { + execFileSync("wezterm", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], { + encoding: "utf8", + }); + return; + } + + zellijActionSync(["write", "27"], surface); +} + +/** + * Send a long command to a pane by writing it to a script file first. + * This avoids terminal line-wrapping issues that break commands exceeding the + * pane's column width when sent character-by-character via sendCommand. + * + * By default the script is written to a temp directory, but callers can pass a + * stable path (for example under session artifacts) so the exact invocation is + * preserved for debugging. + * + * Returns the script path. + */ +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; +} + +/** + * Read the screen contents of a pane (sync). + */ +export function readScreen(surface: string, lines = 50): string { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, { + encoding: "utf8", + }); + } + + if (backend === "tmux") { + return execFileSync( + "tmux", + ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], + { + encoding: "utf8", + }, + ); + } + + if (backend === "wezterm") { + const raw = execFileSync( + "wezterm", + ["cli", "get-text", "--pane-id", surface], + { encoding: "utf8" }, + ); + return tailLines(raw, lines); + } + + // Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file. + // The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen, + // and --path may silently fail to create the file. Stdout capture is robust. + const paneId = zellijPaneId(surface); + const raw = execFileSync( + "zellij", + ["action", "dump-screen", "--pane-id", paneId], + { encoding: "utf8" }, + ); + return tailLines(raw, lines); +} + +/** + * Read the screen contents of a pane (async). + */ +export async function readScreenAsync(surface: string, lines = 50): Promise { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + const { stdout } = await execFileAsync( + "cmux", + ["read-screen", "--surface", surface, "--lines", String(lines)], + { encoding: "utf8" }, + ); + return stdout; + } + + if (backend === "tmux") { + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`], + { encoding: "utf8" }, + ); + return stdout; + } + + if (backend === "wezterm") { + const { stdout } = await execFileAsync( + "wezterm", + ["cli", "get-text", "--pane-id", surface], + { encoding: "utf8" }, + ); + return tailLines(stdout, lines); + } + + // Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file. + const paneId = zellijPaneId(surface); + const { stdout } = await execFileAsync( + "zellij", + ["action", "dump-screen", "--pane-id", paneId], + { encoding: "utf8" }, + ); + return tailLines(stdout, lines); +} + +/** + * Close a pane. + */ +export function closeSurface(surface: string): void { + const backend = requireMuxBackend(); + + if (backend === "cmux") { + execSync(`cmux close-surface --surface ${shellEscape(surface)}`, { + encoding: "utf8", + }); + return; + } + + if (backend === "tmux") { + execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" }); + return; + } + + if (backend === "wezterm") { + execFileSync("wezterm", ["cli", "kill-pane", "--pane-id", surface], { + encoding: "utf8", + }); + return; + } + + zellijActionSync(["close-pane"], surface); +} + +export interface PollResult { + /** How the subagent exited */ + reason: "done" | "ping" | "sentinel" | "error"; + /** Shell exit code (from sentinel). 0 for file-based exits. */ + exitCode: number; + /** Ping data if reason is "ping" */ + ping?: { name: string; message: string }; + /** Error message if reason is "error" (auto-retry exhausted, provider overload, etc.) */ + errorMessage?: string; +} + +/** + * Interpret an `.exit` sidecar payload (written by subagent_done / caller_ping / + * the error path in subagent-done.ts). Centralized so both the fast and slow + * paths in pollForExit decode the payload the same way. + */ +function interpretExitSidecar(data: any): PollResult { + if (data?.type === "ping") { + return { + reason: "ping", + exitCode: 0, + ping: { name: data.name, message: data.message }, + }; + } + if (data?.type === "error") { + const errorMessage = + typeof data.errorMessage === "string" && data.errorMessage.trim() !== "" + ? data.errorMessage + : "Subagent exited with stopReason=error (no errorMessage in sidecar)."; + return { reason: "error", exitCode: 1, errorMessage }; + } + return { reason: "done", exitCode: 0 }; +} + +export const __pollForExitTest__ = { interpretExitSidecar }; + +/** + * Poll until the subagent exits. Checks for a `.exit` sidecar file first + * (written by subagent_done / caller_ping), falling back to the terminal + * sentinel for crash detection. + */ +export async function pollForExit( + surface: string, + signal: AbortSignal, + options: { + interval: number; + sessionFile?: string; + sentinelFile?: string; + onTick?: (elapsed: number) => void; + }, +): Promise { + const start = Date.now(); + + for (;;) { + if (signal.aborted) { + throw new Error("Aborted while waiting for subagent to finish"); + } + + // Fast path: check for .exit sidecar file (written by subagent_done / caller_ping) + if (options.sessionFile) { + try { + const exitFile = `${options.sessionFile}.exit`; + if (existsSync(exitFile)) { + const data = JSON.parse(readFileSync(exitFile, "utf8")); + rmSync(exitFile, { force: true }); + return interpretExitSidecar(data); + } + } catch {} + } + + // Check Claude sentinel file (written by plugin Stop hook) + if (options.sentinelFile) { + try { + if (existsSync(options.sentinelFile)) { + return { reason: "sentinel", exitCode: 0 }; + } + } catch {} + } + + // Slow path: read terminal screen for sentinel (crash detection) + try { + const screen = await readScreenAsync(surface, 5); + const match = screen.match(/__SUBAGENT_DONE_(\d+)__/); + if (match) { + return { reason: "sentinel", exitCode: parseInt(match[1], 10) }; + } + } catch { + // Surface may have been destroyed — check if .exit file appeared in the meantime + if (options.sessionFile) { + try { + const exitFile = `${options.sessionFile}.exit`; + if (existsSync(exitFile)) { + const data = JSON.parse(readFileSync(exitFile, "utf8")); + rmSync(exitFile, { force: true }); + return interpretExitSidecar(data); + } + } catch {} + } + } + + const elapsed = Math.floor((Date.now() - start) / 1000); + options.onTick?.(elapsed); + + await new Promise((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 }); + }); + } +} diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts new file mode 100644 index 0000000..8f2dba9 --- /dev/null +++ b/pi-extension/subagents/index.ts @@ -0,0 +1,2182 @@ +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { keyHint } from "@mariozechner/pi-coding-agent"; +import { Type, type Static } from "@sinclair/typebox"; +import { Box, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + readdirSync, + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + copyFileSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { + isMuxAvailable, + muxSetupHint, + createSurface, + sendLongCommand, + pollForExit, + closeSurface, + getMuxBackend, + sendEscape, + shellEscape, + renameCurrentTab, + renameWorkspace, + readScreen, +} from "./cmux.ts"; + +import { + findLastAssistantMessage, + getNewEntries, + seedSubagentSessionFile, +} from "./session.ts"; +import { + type StatusSnapshot, + type SubagentStatusState, + advanceStatusState, + capStatusLines, + classifyStatus, + createStatusState, + forceStatusAfterInterrupt, + formatStatusAggregate, + formatTransitionLine, + observeStatus, + loadStatusConfig, +} from "./status.ts"; +import { + getSubagentActivityFile, + readSubagentActivityFile, + type ActivityReadResult, + type SubagentActivityState, +} from "./activity.ts"; + +/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */ +const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url)); + +// Survive /reload: clear timers and abort poll loops from the previous module load. +// /reload re-imports this file, giving fresh module-level state, but closures from +// the old module keep running. See https://github.com/HazAT/pi-interactive-subagents/issues/5 +const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval"); +const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval"); +const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller"); + +{ + const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY]; + if (prevInterval) { + clearInterval(prevInterval); + (globalThis as any)[WIDGET_INTERVAL_KEY] = null; + } + const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY]; + if (prevStatusInterval) { + clearInterval(prevStatusInterval); + (globalThis as any)[STATUS_INTERVAL_KEY] = null; + } + const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined; + if (prevAbort) prevAbort.abort(); + (globalThis as any)[POLL_ABORT_KEY] = new AbortController(); +} + +function getModuleAbortSignal(): AbortSignal { + return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal; +} + +const SubagentParams = Type.Object({ + name: Type.String({ description: "Display name for the subagent" }), + task: Type.String({ description: "Task/prompt for the sub-agent" }), + agent: Type.Optional( + Type.String({ + description: + "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Reads ~/.pi/agent/agents/.md for model, tools, skills.", + }), + ), + systemPrompt: Type.Optional( + Type.String({ description: "Appended to system prompt (role instructions)" }), + ), + model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })), + skills: Type.Optional( + Type.String({ description: "Comma-separated skills (overrides agent default)" }), + ), + tools: Type.Optional( + Type.String({ description: "Comma-separated tools (overrides agent default)" }), + ), + cwd: Type.Optional( + Type.String({ + description: + "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.", + }), + ), + fork: Type.Optional( + Type.Boolean({ + description: + "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.", + }), + ), + interactive: Type.Optional( + Type.Boolean({ + description: + "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).", + }), + ), + resumeSessionId: Type.Optional( + Type.String({ + description: + "Resume a previous Claude Code session by its ID. Loads the conversation history and continues where it left off. The session ID is returned in details of every claude tool call. Use this to retry cancelled runs or ask follow-up questions.", + }), + ), +}); + +type SubagentSessionMode = "standalone" | "lineage-only" | "fork"; + +interface AgentDefaults { + model?: string; + tools?: string; + skills?: string; + thinking?: string; + denyTools?: string; + spawning?: boolean; + autoExit?: boolean; + interactive?: boolean; + systemPromptMode?: "append" | "replace"; + sessionMode?: SubagentSessionMode; + cwd?: string; + cli?: string; + body?: string; + disableModelInvocation?: boolean; +} + +type AgentSource = "package" | "global" | "project"; + +interface AgentDefinition extends AgentDefaults { + name: string; + description?: string; + disableModelInvocation: boolean; +} + +interface ListedAgentDefinition extends AgentDefinition { + source: AgentSource; +} + +/** Tools that are gated by `spawning: false` */ +const SPAWNING_TOOLS = new Set([ + "subagent", + "subagent_interrupt", + "subagents_list", + "subagent_resume", +]); + +/** + * Resolve the effective set of denied tool names from agent defaults. + * `spawning: false` expands to all SPAWNING_TOOLS. + * `deny-tools` adds individual tool names on top. + */ +function resolveDenyTools(agentDefs: AgentDefaults | null): Set { + const denied = new Set(); + if (!agentDefs) return denied; + + // spawning: false → deny all spawning tools + if (agentDefs.spawning === false) { + for (const t of SPAWNING_TOOLS) denied.add(t); + } + + // deny-tools: explicit list + if (agentDefs.denyTools) { + for (const t of agentDefs.denyTools + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + denied.add(t); + } + } + + return denied; +} + +/** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */ +function getAgentConfigDir(): string { + return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); +} + +function getBundledAgentsDir(): string { + return join(SUBAGENTS_DIR, "../../agents"); +} + +function getFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); + return match ? match[1].trim() : undefined; +} + +function parseOptionalBoolean(value: string | undefined): boolean | undefined { + return value != null ? value === "true" : undefined; +} + +function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined { + if (value === "standalone" || value === "lineage-only" || value === "fork") { + return value; + } + return undefined; +} + +function parseAgentDefinition(content: string, fallbackName: string): AgentDefinition | null { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + const frontmatter = match[1]; + const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim(); + const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt"); + + return { + name: getFrontmatterValue(frontmatter, "name") ?? fallbackName, + description: getFrontmatterValue(frontmatter, "description"), + model: getFrontmatterValue(frontmatter, "model"), + tools: getFrontmatterValue(frontmatter, "tools"), + systemPromptMode: + systemPromptMode === "replace" + ? "replace" + : systemPromptMode === "append" + ? "append" + : undefined, + skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"), + thinking: getFrontmatterValue(frontmatter, "thinking"), + denyTools: getFrontmatterValue(frontmatter, "deny-tools"), + spawning: parseOptionalBoolean(getFrontmatterValue(frontmatter, "spawning")), + autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")), + interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")), + sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")), + cwd: getFrontmatterValue(frontmatter, "cwd"), + cli: getFrontmatterValue(frontmatter, "cli"), + body: body || undefined, + disableModelInvocation: + getFrontmatterValue(frontmatter, "disable-model-invocation")?.toLowerCase() === "true", + }; +} + +function discoverAgentDefinitions(): ListedAgentDefinition[] { + const agents = new Map(); + const dirs: Array<{ path: string; source: AgentSource }> = [ + { path: getBundledAgentsDir(), source: "package" }, + { path: join(getAgentConfigDir(), "agents"), source: "global" }, + { path: join(process.cwd(), ".pi", "agents"), source: "project" }, + ]; + + for (const { path: dir, source } of dirs) { + if (!existsSync(dir)) continue; + for (const file of readdirSync(dir).filter((entry) => entry.endsWith(".md"))) { + const parsed = parseAgentDefinition( + readFileSync(join(dir, file), "utf8"), + file.replace(/\.md$/, ""), + ); + if (!parsed) continue; + agents.set(parsed.name, { ...parsed, source }); + } + } + + return [...agents.values()]; +} + +function resolveSubagentPaths( + params: Static, + agentDefs: AgentDefaults | null, +): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } { + const rawCwd = params.cwd ?? agentDefs?.cwd ?? null; + const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null; + const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd(); + const effectiveCwd = rawCwd + ? rawCwd.startsWith("/") + ? rawCwd + : join(cwdBase, rawCwd) + : null; + const localAgentDir = effectiveCwd ? join(effectiveCwd, ".pi", "agent") : null; + const effectiveAgentDir = + localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir(); + return { effectiveCwd, localAgentDir, effectiveAgentDir }; +} + +function getDefaultSessionDirFor(cwd: string, agentDir: string): string { + const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + const sessionDir = join(agentDir, "sessions", safePath); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +function resolveEffectiveSessionMode( + params: Static, + agentDefs: AgentDefaults | null, +): SubagentSessionMode { + if (params.fork) return "fork"; + return agentDefs?.sessionMode ?? "standalone"; +} + +function resolveLaunchBehavior( + params: Static, + agentDefs: AgentDefaults | null, +): { + sessionMode: SubagentSessionMode; + seededSessionMode: "lineage-only" | "fork" | null; + inheritsConversationContext: boolean; + taskDelivery: "direct" | "artifact"; +} { + const sessionMode = resolveEffectiveSessionMode(params, agentDefs); + const inheritsConversationContext = sessionMode === "fork"; + return { + sessionMode, + seededSessionMode: sessionMode === "standalone" ? null : sessionMode, + inheritsConversationContext, + taskDelivery: inheritsConversationContext ? "direct" : "artifact", + }; +} + +/** + * Decide whether a subagent is interactive (user-driven, long-running). + * + * Resolution order: + * 1. Explicit `interactive` tool parameter wins. + * 2. Explicit `interactive` frontmatter field on the agent. + * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are + * autonomous (scout, worker, reviewer) and the parent session should be + * woken on stall/recovery transitions. Agents that don't auto-exit are + * driven by the user in their own pane (planner, iterate/fork) and + * stall pings are noise. + * + * When no agent defs exist at all (bare `subagent({ name, task })` call, + * typical for `/iterate` with `fork: true`), `autoExit` is undefined and the + * subagent is treated as interactive — matching the intent of iterate. + */ +function resolveEffectiveInteractive( + params: Static, + agentDefs: AgentDefaults | null, +): boolean { + if (params.interactive != null) return params.interactive; + if (agentDefs?.interactive != null) return agentDefs.interactive; + return !(agentDefs?.autoExit ?? false); +} + +function loadAgentDefaults(agentName: string): AgentDefaults | null { + const configDir = getAgentConfigDir(); + const paths = [ + join(process.cwd(), ".pi", "agents", `${agentName}.md`), + join(configDir, "agents", `${agentName}.md`), + join(getBundledAgentsDir(), `${agentName}.md`), + ]; + + for (const p of paths) { + if (!existsSync(p)) continue; + const parsed = parseAgentDefinition(readFileSync(p, "utf8"), agentName); + if (parsed) return parsed; + } + + return null; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m ${s}s`; +} + +/** + * Wait long enough for a freshly created pane to finish shell startup. + * + * Some environments do extra shell-init work before the prompt is ready + * (for example direnv/devenv), so the delay is configurable for users who hit + * dropped commands. Keep the historical default at 500ms. + */ +function getShellReadyDelayMs(): number { + const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim(); + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500; +} + +function muxUnavailableResult() { + return { + content: [ + { + type: "text" as const, + text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`, + }, + ], + details: { error: "mux not available" }, + }; +} + +/** + * Build the internal artifact directory path for the current session. + * Used by the subagents extension to stash task files, system prompts, and + * launch scripts for sub-agents. Path convention: + * /artifacts// + */ +function getArtifactDir(sessionDir: string, sessionId: string): string { + return join(sessionDir, "artifacts", sessionId); +} + +const statusConfig = loadStatusConfig(); + +function formatWidgetRightLabel(snapshot: StatusSnapshot): string { + if (snapshot.kind === "starting") return " starting… "; + if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `; + if (snapshot.kind === "active") { + const label = snapshot.activityLabel ?? snapshot.activeScope; + const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : ""; + return label ? ` active · ${label}${duration} ` : " active "; + } + if (snapshot.kind === "waiting") { + const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : ""; + const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : ""; + return ` waiting${duration}${detail} `; + } + + const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : ""; + const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : ""; + return ` stalled${detail}${duration} `; +} + +function resolveResultPresentation( + result: Pick< + SubagentResult, + "exitCode" | "elapsed" | "summary" | "sessionFile" | "errorMessage" + >, + name: string, +): string { + const sessionRef = result.sessionFile + ? `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}` + : ""; + + if (result.errorMessage) { + // Auto-retry exhausted or other agent-loop error. The subagent did not + // produce a usable result — surface the underlying provider/network + // failure so the orchestrator can decide whether to retry, resume, or + // change approach instead of silently treating the run as completed. + return ( + `Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` + + `(provider/agent error — auto-retry exhausted).\n\n` + + `Error: ${result.errorMessage}\n\n` + + `The subagent did not produce a result. You can retry by spawning a new ` + + `subagent or resume the session with subagent_resume.${sessionRef}` + ); + } + + return result.exitCode !== 0 + ? `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionRef}` + : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionRef}`; +} + +/** + * Result from running a single subagent. + */ +interface SubagentResult { + name: string; + task: string; + summary: string; + sessionFile?: string; + claudeSessionId?: string; + exitCode: number; + elapsed: number; + error?: string; + /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */ + errorMessage?: string; + ping?: { name: string; message: string }; +} + +/** + * State for a launched (but not yet completed) subagent. + */ +interface RunningSubagent { + id: string; + name: string; + task: string; + agent?: string; + surface: string; + startTime: number; + sessionFile: string; + launchScriptFile?: string; + activityFile?: string; + activity?: SubagentActivityState; + activityRead?: { + ok: boolean; + reason?: "missing" | "invalid" | "wrong-id"; + error?: string; + }; + abortController?: AbortController; + cli?: string; + sentinelFile?: string; + statusState: SubagentStatusState; + /** + * When true, status transitions (stalled/recovered) do not wake the parent + * session via a steer message. The widget still updates locally. Used for + * long-running agents where the user drives the conversation in the + * subagent's pane (e.g. planner). + */ + interactive: boolean; +} + +/** All currently running subagents, keyed by id. */ +const runningSubagents = new Map(); + +// ── Widget management ── + +/** Latest ExtensionContext from session_start, used for widget updates. */ +let latestCtx: ExtensionContext | null = null; + +/** Interval timer for widget re-renders. */ +let widgetInterval: ReturnType | null = null; + +/** Interval timer for status transition checks. */ +let statusInterval: ReturnType | null = null; + +function formatElapsedMMSS(startTime: number): string { + const seconds = Math.floor((Date.now() - startTime) / 1000); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +const ACCENT = "\x1b[38;2;77;163;255m"; +const RST = "\x1b[0m"; + +/** + * Build a bordered content line: │left right│ + * Left content is truncated if needed, right is preserved, padded to fill width. + */ +function borderLine(left: string, right: string, width: number): string { + if (width <= 0) return ""; + if (width === 1) return `${ACCENT}│${RST}`; + + // width = total visible chars for the whole line including │ and │ + const contentWidth = Math.max(0, width - 2); // space inside the two │ chars + const rightVis = visibleWidth(right); + + // If the status chunk alone is too wide, prefer preserving it in compact form + // rather than overflowing the terminal. + if (rightVis >= contentWidth) { + const truncRight = truncateToWidth(right, contentWidth); + const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight)); + return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`; + } + + const maxLeft = Math.max(0, contentWidth - rightVis); + const truncLeft = truncateToWidth(left, maxLeft); + const leftVis = visibleWidth(truncLeft); + const pad = Math.max(0, contentWidth - leftVis - rightVis); + return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`; +} + +/** + * Build the bordered top line: ╭─ Title ──── info ─╮ + * All chars are accounted for within `width`. + */ +function borderTop(title: string, info: string, width: number): string { + if (width <= 0) return ""; + if (width === 1) return `${ACCENT}╭${RST}`; + + // ╭─ Title ───...─── info ─╮ + // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify + const inner = Math.max(0, width - 2); // inside ╭ and ╮ + const titlePart = `─ ${title} `; + const infoPart = ` ${info} ─`; + const fillLen = Math.max(0, inner - titlePart.length - infoPart.length); + const fill = "─".repeat(fillLen); + const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─"); + return `${ACCENT}╭${content}╮${RST}`; +} + +/** + * Build the bordered bottom line: ╰──────────────────╯ + */ +function borderBottom(width: number): string { + if (width <= 0) return ""; + if (width === 1) return `${ACCENT}╰${RST}`; + + const inner = Math.max(0, width - 2); + return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`; +} + +function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] { + const count = agents.length; + const title = "Subagents"; + const info = `${count} running`; + + const lines: string[] = [borderTop(title, info, width)]; + + for (const agent of agents) { + const elapsed = formatElapsedMMSS(agent.startTime); + const agentTag = agent.agent ? ` (${agent.agent})` : ""; + const left = ` ${elapsed} ${agent.name}${agentTag} `; + const snapshot = classifyStatus(agent.statusState, Date.now()); + const right = statusConfig.enabled + ? formatWidgetRightLabel(snapshot) + : agent.cli === "claude" + ? " running… " + : " starting… "; + + lines.push(borderLine(left, right, width)); + } + + lines.push(borderBottom(width)); + return lines; +} + +function updateWidget() { + if (!latestCtx?.hasUI) return; + + if (runningSubagents.size === 0) { + latestCtx.ui.setWidget("subagent-status", undefined); + if (widgetInterval) { + clearInterval(widgetInterval); + widgetInterval = null; + (globalThis as any)[WIDGET_INTERVAL_KEY] = null; + } + return; + } + + latestCtx.ui.setWidget( + "subagent-status", + (_tui: any, _theme: any) => { + return { + invalidate() {}, + render(width: number) { + return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width); + }, + }; + }, + { placement: "aboveEditor" }, + ); +} + +/** + * Build the positional prompt args for a Pi CLI subagent launch. + * + * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage() + * concatenates @file content with messages[0] into one initial prompt. That breaks + * /skill: expansion because the message no longer starts with "/skill:". Only + * messages[1..] are sent as separate follow-up prompts where /skill: is recognized. + * + * When there are skill prompts AND artifact-backed delivery, we prepend an empty + * first positional message so that /skill: args land in messages[1..] and arrive + * as standalone prompts in the child session. + */ +const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const; + +/** + * Build the child --tools allowlist. + * + * Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a + * subagent definition restricts tools to e.g. "read,bash,write", the child + * control tools from subagent-done.ts would otherwise be hidden, leaving a + * manually resumed or user-touched subagent unable to call subagent_done. + */ +function buildSubagentToolAllowlist(effectiveTools?: string): string | null { + const requested = (effectiveTools ?? "") + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean); + + if (requested.length === 0) return null; + + const allow = new Set(requested); + for (const tool of SUBAGENT_CONTROL_TOOLS) { + allow.add(tool); + } + + return [...allow].join(","); +} + +function buildPiPromptArgs(params: { + effectiveSkills?: string; + taskDelivery: "direct" | "artifact"; + taskArg: string; +}): string[] { + const skillPrompts = (params.effectiveSkills ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((skill) => `/skill:${skill}`); + + const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0; + + return [ + ...(needsSeparator ? [""] : []), + ...skillPrompts, + params.taskArg, + ]; +} + +function activityLabel(activity: SubagentActivityState): string | undefined { + if (activity.phase !== "active") return undefined; + if (activity.activeScope === "tool") return activity.toolName ?? "tool"; + if (activity.activeScope === "provider") return "provider"; + if (activity.activeScope === "streaming") return "streaming"; + return activity.activeScope; +} + +function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now()) { + if (running.cli === "claude") return; + + const activityFile = running.activityFile; + const read: ActivityReadResult = activityFile + ? readSubagentActivityFile(activityFile, running.id) + : { ok: false, reason: "missing" }; + + running.activityRead = read.ok + ? { ok: true } + : { ok: false, reason: read.reason, error: read.error }; + + if (read.ok) { + running.activity = read.activity; + running.statusState = observeStatus(running.statusState, { + snapshot: "present", + updatedAt: read.activity.updatedAt, + sequence: read.activity.sequence, + phase: read.activity.phase, + active: read.activity.phase === "active", + activeScope: read.activity.activeScope, + activeSince: read.activity.activeSince, + waitingSince: read.activity.waitingSince, + latestEvent: read.activity.latestEvent, + activityLabel: activityLabel(read.activity), + }, observedAt); + return; + } + + running.statusState = observeStatus(running.statusState, { + snapshot: read.reason, + snapshotError: read.error, + }, observedAt); +} + +function resolveInterruptTarget(params: { id?: string; name?: string }): + | { running: RunningSubagent } + | { error: string } { + const requestedId = params.id?.trim(); + if (requestedId) { + const running = runningSubagents.get(requestedId); + return running ? { running } : { error: `No running subagent with id "${requestedId}".` }; + } + + const requestedName = params.name?.trim(); + if (!requestedName) { + return { error: "Provide a running subagent id or exact display name." }; + } + + const matches = Array.from(runningSubagents.values()).filter((running) => running.name === requestedName); + if (matches.length === 1) return { running: matches[0] }; + if (matches.length === 0) { + return { error: `No running subagent named "${requestedName}".` }; + } + + const candidates = matches.map((running) => `${running.name} [${running.id}]`).join(", "); + return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}` }; +} + +function requestSubagentInterrupt( + running: RunningSubagent, + sendEscapeKey: (surface: string) => void = sendEscape, +): { ok: true } | { error: string } { + try { + sendEscapeKey(running.surface); + return { ok: true }; + } catch (error: any) { + const backend = getMuxBackend() ?? "unknown"; + return { + error: + `Failed to send Escape to subagent "${running.name}" via ${backend}: ` + + `${error?.message ?? String(error)}`, + }; + } +} + +function handleSubagentInterrupt( + params: { id?: string; name?: string }, + sendEscapeKey: (surface: string) => void = sendEscape, +) { + const resolved = resolveInterruptTarget(params); + if ("error" in resolved) { + return { + content: [{ type: "text" as const, text: resolved.error }], + details: { error: resolved.error }, + }; + } + + const running = resolved.running; + if (running.cli === "claude") { + return { + content: [{ + type: "text" as const, + text: + "Turn-only Escape interrupt is currently supported only for Pi-backed subagents. Claude-backed semantics have not been verified yet.", + }], + details: { error: "claude interrupt unsupported", id: running.id, name: running.name }, + }; + } + + const now = Date.now(); + observeRunningSubagent(running, now); + + const interruption = requestSubagentInterrupt(running, sendEscapeKey); + if ("error" in interruption) { + return { + content: [{ type: "text" as const, text: interruption.error }], + details: { error: interruption.error, id: running.id, name: running.name }, + }; + } + + running.statusState = forceStatusAfterInterrupt(running.statusState, now); + updateWidget(); + + return { + content: [{ type: "text" as const, text: `Interrupt requested for subagent "${running.name}".` }], + details: { id: running.id, name: running.name, status: "interrupt_requested" }, + }; +} + +function startStatusRefresh(pi: ExtensionAPI) { + if (!statusConfig.enabled || statusInterval) return; + + statusInterval = setInterval(() => { + if (runningSubagents.size === 0) { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; + (globalThis as any)[STATUS_INTERVAL_KEY] = null; + } + return; + } + + const transitionLines: string[] = []; + const now = Date.now(); + let shouldRefreshWidget = false; + + for (const running of runningSubagents.values()) { + observeRunningSubagent(running, now); + const { nextState, snapshot, transition } = advanceStatusState(running.statusState, now); + if (nextState.currentKind !== running.statusState.currentKind) { + shouldRefreshWidget = true; + } + running.statusState = nextState; + + // Interactive subagents (long-running, user-driven) intentionally don't + // wake the parent session on stalled/recovered transitions — the user is + // working in the subagent's pane, and a steer message here would burn an + // orchestrator turn on a no-op "still waiting" ping. Widget still updates. + if (transition && !running.interactive) { + transitionLines.push(formatTransitionLine(running.name, snapshot, transition)); + } + } + + if (shouldRefreshWidget) updateWidget(); + + if (transitionLines.length > 0) { + const capped = capStatusLines(transitionLines, statusConfig.lineLimit); + pi.sendMessage( + { + customType: "subagent_status", + content: formatStatusAggregate(transitionLines, statusConfig.lineLimit), + display: true, + details: { lines: capped.visibleLines, overflow: capped.overflow }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + } + }, 1000); + + (globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval; +} + +function resolveResumeLaunchBehavior(params: { autoExit?: boolean }): { autoExit: boolean; interactive: boolean } { + const autoExit = params.autoExit ?? true; + return { autoExit, interactive: !autoExit }; +} + +export const __test__ = { + borderLine, + getShellReadyDelayMs, + renderSubagentWidgetLines, + loadAgentDefaults, + discoverAgentDefinitions, + resolveEffectiveSessionMode, + resolveLaunchBehavior, + resolveEffectiveInteractive, + buildSubagentToolAllowlist, + buildPiPromptArgs, + formatWidgetRightLabel, + observeRunningSubagent, + resolveDenyTools, + resolveInterruptTarget, + requestSubagentInterrupt, + handleSubagentInterrupt, + resolveResultPresentation, + resolveResumeLaunchBehavior, + runningSubagents, + formatElapsed, +}; + +function startWidgetRefresh() { + if (widgetInterval) return; + updateWidget(); // immediate first render + widgetInterval = setInterval(() => { + updateWidget(); + }, 1000); + (globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval; +} + +/** + * Launch a subagent: creates the multiplexer pane, builds the command, and + * sends it. Returns a RunningSubagent — does NOT poll. + * + * Call watchSubagent() on the returned object to observe completion. + */ +async function launchSubagent( + params: typeof SubagentParams.static, + ctx: { sessionManager: { getSessionFile(): string | null; getSessionId(): string; getSessionDir(): string }; cwd: string }, + options?: { surface?: string }, +): Promise { + const startTime = Date.now(); + const id = Math.random().toString(16).slice(2, 10); + + const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null; + const effectiveModel = params.model ?? agentDefs?.model; + const effectiveTools = params.tools ?? agentDefs?.tools; + const effectiveSkills = params.skills ?? agentDefs?.skills; + const effectiveThinking = agentDefs?.thinking; + const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs); + + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("No session file"); + const sessionId = ctx.sessionManager.getSessionId(); + const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); + + const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs); + const targetCwdForSession = effectiveCwd ?? ctx.cwd; + const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir); + + // Generate a deterministic session file path for this subagent. + // This eliminates race conditions when multiple agents launch simultaneously — + // each agent knows exactly which file is theirs. + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z"; + const uuid = [ + id, + Math.random().toString(16).slice(2, 10), + Math.random().toString(16).slice(2, 10), + Math.random().toString(16).slice(2, 6), + ].join("-"); + const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`); + + // Use pre-created surface (parallel mode) or create a new one. + // For new surfaces, pause briefly so the shell is ready before sending the command. + const surfacePreCreated = !!options?.surface; + const surface = options?.surface ?? createSurface(params.name); + if (!surfacePreCreated) { + await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); + } + + const launchBehavior = resolveLaunchBehavior(params, agentDefs); + + if (launchBehavior.seededSessionMode) { + seedSubagentSessionFile({ + mode: launchBehavior.seededSessionMode, + parentSessionFile: sessionFile, + childSessionFile: subagentSessionFile, + childCwd: targetCwdForSession, + }); + } + + const activityFile = getSubagentActivityFile(artifactDir, id); + mkdirSync(dirname(activityFile), { recursive: true }); + const { inheritsConversationContext } = launchBehavior; + + // Build the task message + // Only full-context fork mode inherits prior conversation state. + // Blank-session modes need the wrapper instructions and artifact-backed handoff. + const modeHint = agentDefs?.autoExit + ? "Complete your task autonomously." + : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time."; + const summaryInstruction = agentDefs?.autoExit + ? "Your FINAL assistant message should summarize what you accomplished." + : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished."; + const denySet = resolveDenyTools(agentDefs); + const identity = agentDefs?.body ?? params.systemPrompt ?? null; + const systemPromptMode = agentDefs?.systemPromptMode; + const identityInSystemPrompt = systemPromptMode && identity; + const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : ""; + const fullTask = inheritsConversationContext + ? params.task + : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`; + // ── Claude Code CLI path ── + if (agentDefs?.cli === "claude") { + const sentinelFile = `/tmp/pi-claude-${id}-done`; + const pluginDir = join(SUBAGENTS_DIR, "plugin"); + + const cmdParts: string[] = []; + cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`); + cmdParts.push("claude"); + cmdParts.push("--dangerously-skip-permissions"); + + if (existsSync(pluginDir)) { + cmdParts.push("--plugin-dir", shellEscape(pluginDir)); + } + + if (effectiveModel) { + cmdParts.push("--model", shellEscape(effectiveModel)); + } + + const sp = params.systemPrompt ?? agentDefs.body; + if (sp) { + cmdParts.push("--append-system-prompt", shellEscape(sp)); + } + + if (params.resumeSessionId) { + cmdParts.push("--resume", shellEscape(params.resumeSessionId)); + } + + // Always pass the task as the prompt — even for resumed sessions, + // the caller's task is the follow-up instruction. + cmdParts.push(shellEscape(params.task)); + + const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; + const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`; + + const launchScriptName = `${(params.name || "subagent") + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`; + const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName); + + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Claude Code subagent launch script for ${params.name}`, + `# Generated: ${new Date().toISOString()}`, + `# Surface: ${surface}`, + ].join("\n"), + }); + + const running: RunningSubagent = { + id, + name: params.name, + task: params.task, + agent: params.agent, + surface, + startTime, + sessionFile: subagentSessionFile, + launchScriptFile, + cli: "claude", + sentinelFile, + interactive: effectiveInteractive, + statusState: createStatusState({ + source: "claude", + startTimeMs: startTime, + }), + }; + + runningSubagents.set(id, running); + return running; + } + + // ── Pi CLI path ── + + // Build pi command + const parts: string[] = ["pi"]; + parts.push("--session", shellEscape(subagentSessionFile)); + + const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts"); + parts.push("-e", shellEscape(subagentDonePath)); + + if (effectiveModel) { + const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel; + parts.push("--model", shellEscape(model)); + } + + // Pass agent body as system prompt via file to avoid shell escaping issues + // with multiline content. Pi's --append-system-prompt and --system-prompt + // auto-detect file paths and read their contents. + if (identityInSystemPrompt && identity) { + const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt"; + const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const spSafeName = params.name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + const syspromptPath = join(artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`); + mkdirSync(dirname(syspromptPath), { recursive: true }); + writeFileSync(syspromptPath, identity, "utf8"); + parts.push(flag, shellEscape(syspromptPath)); + } + + const toolAllowlist = buildSubagentToolAllowlist(effectiveTools); + if (toolAllowlist) { + parts.push("--tools", shellEscape(toolAllowlist)); + } + + // Build env prefix: denied tools + subagent identity + config dir propagation + const envParts: string[] = []; + + // If the target cwd has its own .pi/agent/, use that as the config root. + // Otherwise propagate the current/global agent dir. + if (localAgentDir && existsSync(localAgentDir)) { + envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(localAgentDir)}`); + } else if (process.env.PI_CODING_AGENT_DIR) { + envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`); + } + + if (denySet.size > 0) { + envParts.push(`PI_DENY_TOOLS=${shellEscape([...denySet].join(","))}`); + } + envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`); + if (params.agent) { + envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`); + } + if (agentDefs?.autoExit) { + envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`); + } + envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`); + envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`); + envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`); + envParts.push(`PI_SUBAGENT_SURFACE=${shellEscape(surface)}`); + const envPrefix = envParts.join(" ") + " "; + + // Pass task and skill prompts to the sub-agent. + // Only full-context fork mode gets a direct task argument because it already + // inherits the parent conversation. Blank-session modes use artifact-backed + // handoff so the wrapper instructions arrive as the initial user message. + let taskArg: string; + if (launchBehavior.taskDelivery === "direct") { + taskArg = fullTask; + } else { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const safeName = params.name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens + .replace(/\s+/g, "-") // spaces to hyphens + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, ""); // trim leading/trailing hyphens + const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`; + const artifactPath = join(artifactDir, artifactName); + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, fullTask, "utf8"); + taskArg = `@${artifactPath}`; + } + + for (const promptArg of buildPiPromptArgs({ + effectiveSkills, + taskDelivery: launchBehavior.taskDelivery, + taskArg, + })) { + parts.push(shellEscape(promptArg)); + } + + // Resolve cwd — param overrides agent default, supports absolute and relative paths. + // This was already computed above so session placement, PI_CODING_AGENT_DIR, and cd agree. + const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : ""; + + const piCommand = cdPrefix + envPrefix + parts.join(" "); + const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`; + const launchScriptName = `${(params.name || "subagent") + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "subagent"}-${id}.sh`; + const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName); + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Subagent launch script for ${params.name}`, + `# Generated: ${new Date().toISOString()}`, + `# Session: ${subagentSessionFile}`, + `# Surface: ${surface}`, + ].join("\n"), + }); + + const running: RunningSubagent = { + id, + name: params.name, + task: params.task, + agent: params.agent, + surface, + startTime, + sessionFile: subagentSessionFile, + launchScriptFile, + activityFile, + interactive: effectiveInteractive, + statusState: createStatusState({ + source: "pi", + startTimeMs: startTime, + }), + }; + + runningSubagents.set(id, running); + return running; +} + +/** + * Watch a launched subagent until it exits. Polls for completion, extracts + * the summary from the session file, cleans up the surface, + * and removes the entry from runningSubagents. + */ +const CLAUDE_SESSIONS_DIR = join( + process.env.HOME ?? "/tmp", + ".pi", "agent", "sessions", "claude-code", +); + +function copyClaudeSession(sentinelFile: string): string | null { + try { + const transcriptFile = sentinelFile + ".transcript"; + if (!existsSync(transcriptFile)) return null; + const transcriptPath = readFileSync(transcriptFile, "utf-8").trim(); + if (!transcriptPath || !existsSync(transcriptPath)) return null; + mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); + const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`; + const dest = join(CLAUDE_SESSIONS_DIR, filename); + copyFileSync(transcriptPath, dest); + return filename; + } catch { + return null; + } +} + +async function watchSubagent( + running: RunningSubagent, + signal: AbortSignal, +): Promise { + const { name, task, surface, startTime, sessionFile } = running; + + try { + const result = await pollForExit(surface, AbortSignal.any([signal, getModuleAbortSignal()]), { + interval: 1000, + sessionFile, + sentinelFile: running.sentinelFile, + onTick() { + observeRunningSubagent(running); + }, + }); + + const elapsed = Math.floor((Date.now() - startTime) / 1000); + + if (running.cli === "claude") { + // Claude Code result extraction + let summary = ""; + + if (running.sentinelFile) { + try { + summary = readFileSync(running.sentinelFile, "utf-8").trim(); + } catch {} + } + + if (!summary) { + summary = readScreen(surface, 200) + .replace(/__SUBAGENT_DONE_\d+__/, "") + .trimEnd(); + } + + if (!summary) { + summary = result.exitCode !== 0 + ? `Claude Code exited with code ${result.exitCode}` + : "Claude Code exited without output"; + } + + // Copy Claude session transcript + let sessionId: string | null = null; + if (running.sentinelFile) { + sessionId = copyClaudeSession(running.sentinelFile); + try { unlinkSync(running.sentinelFile); } catch {} + try { unlinkSync(running.sentinelFile + ".transcript"); } catch {} + } + + closeSurface(surface); + runningSubagents.delete(running.id); + + return { name, task, summary, exitCode: result.exitCode, elapsed, ...(sessionId ? { claudeSessionId: sessionId } : {}) }; + } + + // Pi subagent result extraction + let summary: string; + if (existsSync(sessionFile)) { + const allEntries = getNewEntries(sessionFile, 0); + summary = + findLastAssistantMessage(allEntries) ?? + (result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Sub-agent exited with code ${result.exitCode}` + : "Sub-agent exited without output"); + } else { + summary = result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Sub-agent exited with code ${result.exitCode}` + : "Sub-agent exited without output"; + } + + closeSurface(surface); + runningSubagents.delete(running.id); + + return { + name, + task, + summary, + sessionFile, + exitCode: result.exitCode, + elapsed, + ping: result.ping, + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + }; + } catch (err: any) { + try { + closeSurface(surface); + } catch {} + runningSubagents.delete(running.id); + + if (signal.aborted) { + return { + name, + task, + summary: "Subagent cancelled.", + exitCode: 1, + elapsed: Math.floor((Date.now() - startTime) / 1000), + error: "cancelled", + sessionFile, + }; + } + return { + name, + task, + summary: `Subagent error: ${err?.message ?? String(err)}`, + exitCode: 1, + elapsed: Math.floor((Date.now() - startTime) / 1000), + error: err?.message ?? String(err), + }; + } +} + +export default function subagentsExtension(pi: ExtensionAPI) { + // Capture the UI context for widget updates + pi.on("session_start", (_event, ctx) => { + latestCtx = ctx; + }); + + // Clean up on session shutdown + pi.on("session_shutdown", (_event, _ctx) => { + if (widgetInterval) { + clearInterval(widgetInterval); + widgetInterval = null; + (globalThis as any)[WIDGET_INTERVAL_KEY] = null; + } + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; + (globalThis as any)[STATUS_INTERVAL_KEY] = null; + } + const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined; + if (moduleAbort) moduleAbort.abort(); + for (const [_id, agent] of runningSubagents) { + agent.abortController?.abort(); + } + runningSubagents.clear(); + }); + + // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter) + const deniedTools = new Set( + (process.env.PI_DENY_TOOLS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + + const shouldRegister = (name: string) => !deniedTools.has(name); + + // ── subagent tool ── + if (shouldRegister("subagent")) + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: + "Spawn a sub-agent in a dedicated terminal multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate, assume, or summarize results after calling this tool. " + + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", + promptSnippet: + "Spawn a sub-agent in a dedicated terminal multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate, assume, or summarize results after calling this tool. " + + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", + parameters: SubagentParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + // Prevent self-spawning (e.g. planner spawning another planner) + const currentAgent = process.env.PI_SUBAGENT_AGENT; + if (params.agent && currentAgent && params.agent === currentAgent) { + return { + content: [ + { + type: "text", + text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`, + }, + ], + details: { error: "self-spawn blocked" }, + }; + } + + // Validate prerequisites + if (!isMuxAvailable()) { + return muxUnavailableResult(); + } + + if (!ctx.sessionManager.getSessionFile()) { + return { + content: [ + { + type: "text", + text: "Error: no session file. Start pi with a persistent session to use subagents.", + }, + ], + details: { error: "no session file" }, + }; + } + + // Launch the subagent (creates pane, sends command) + const running = await launchSubagent(params, ctx); + + // Create a separate AbortController for the watcher + // (the tool's signal completes when we return) + const watcherAbort = new AbortController(); + running.abortController = watcherAbort; + + // Start widget refresh and status supervision when the first agent launches + startWidgetRefresh(); + startStatusRefresh(pi); + + // Fire-and-forget: start watching in background + watchSubagent(running, watcherAbort.signal) + .then((result) => { + updateWidget(); // reflect removal from Map immediately + + if (result.ping) { + // Subagent is requesting help — steer a ping message with session path for resume + const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`; + pi.sendMessage( + { + customType: "subagent_ping", + content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`, + display: true, + details: { + name: result.ping.name, + message: result.ping.message, + agent: running.agent, + sessionFile: result.sessionFile, + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + return; + } + + const presentation = resolveResultPresentation(result, running.name); + + pi.sendMessage( + { + customType: "subagent_result", + content: presentation, + display: true, + details: { + name: running.name, + task: running.task, + agent: running.agent, + exitCode: result.exitCode, + elapsed: result.elapsed, + sessionFile: result.sessionFile, + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + ...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }) + .catch((err) => { + updateWidget(); + pi.sendMessage( + { + customType: "subagent_result", + content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`, + display: true, + details: { name: running.name, task: running.task, error: err?.message }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + + // Return immediately + return { + content: [ + { + type: "text", + text: + `Sub-agent "${params.name}" launched and is now running in the background. ` + + `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` + + `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` + + `Until then, move on to other work or tell the user you're waiting.`, + }, + ], + details: { + id: running.id, + name: params.name, + task: params.task, + agent: params.agent, + sessionFile: running.sessionFile, + launchScriptFile: running.launchScriptFile, + status: "started", + }, + }; + }, + + renderCall(args, theme) { + const partialArgs = args as Record; + const name = typeof partialArgs.name === "string" && partialArgs.name ? partialArgs.name : "(unnamed)"; + const task = typeof partialArgs.task === "string" ? partialArgs.task : ""; + const agent = typeof partialArgs.agent === "string" && partialArgs.agent + ? theme.fg("dim", ` (${partialArgs.agent})`) + : ""; + const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd + ? theme.fg("dim", ` in ${partialArgs.cwd}`) + : ""; + let text = + "▸ " + + theme.fg("toolTitle", theme.bold(name)) + + agent + + cwdHint; + + // Show a one-line task preview. renderCall is called repeatedly as the + // LLM generates tool arguments, so args.task grows token by token. + // We keep it compact here — Ctrl+O on renderResult expands the full content. + if (task) { + const firstLine = task.split("\n").find((l: string) => l.trim()) ?? ""; + const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine; + if (preview) { + text += "\n" + theme.fg("toolOutput", preview); + } + const totalLines = task.split("\n").length; + if (totalLines > 1) { + text += theme.fg("muted", ` (${totalLines} lines)`); + } + } + + return new Text(text, 0, 0); + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + const name = details?.name ?? "(unnamed)"; + + // "Started" result — tool returned immediately + if (details?.status === "started") { + return new Text( + theme.fg("accent", "▸") + + " " + + theme.fg("toolTitle", theme.bold(name)) + + theme.fg("dim", " — started"), + 0, + 0, + ); + } + + // Fallback (shouldn't happen) + const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; + return new Text(theme.fg("dim", text), 0, 0); + }, + }); + + // ── subagent_interrupt tool ── + if (shouldRegister("subagent_interrupt")) + pi.registerTool({ + name: "subagent_interrupt", + label: "Interrupt Subagent", + description: + "Send Escape to the active turn of a currently running Pi-backed subagent. " + + "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + + "and does not emit a subagent_result solely because of this request.", + promptSnippet: + "Send Escape to the active turn of a currently running Pi-backed subagent. " + + "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + + "and does not emit a subagent_result solely because of this request.", + parameters: Type.Object({ + id: Type.Optional(Type.String({ description: "Exact running subagent id" })), + name: Type.Optional(Type.String({ description: "Exact running subagent display name" })), + }), + + async execute(_toolCallId, params) { + return handleSubagentInterrupt(params); + }, + + renderCall(args, theme) { + const target = args.id ? `${args.id}` : args.name ?? "(unknown)"; + return new Text( + theme.fg("accent", "▸") + + " " + + theme.fg("toolTitle", theme.bold(target)) + + theme.fg("dim", " — interrupt turn"), + 0, + 0, + ); + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + if (details?.status === "interrupt_requested") { + return new Text( + theme.fg("accent", "▸") + + " " + + theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) + + theme.fg("dim", " — interrupt requested"), + 0, + 0, + ); + } + + const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; + return new Text(theme.fg("dim", text), 0, 0); + }, + }); + + // ── subagents_list tool ── + if (shouldRegister("subagents_list")) + pi.registerTool({ + name: "subagents_list", + label: "List Subagents", + description: + "List all available subagent definitions. " + + "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " + + "Project-local agents override global ones with the same name.", + promptSnippet: + "List all available subagent definitions. " + + "Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " + + "Project-local agents override global ones with the same name.", + parameters: Type.Object({}), + + async execute() { + const list = discoverAgentDefinitions().filter((agent) => !agent.disableModelInvocation); + + if (list.length === 0) { + return { + content: [{ type: "text", text: "No subagent definitions found." }], + details: { agents: [] }, + }; + } + + const lines = list.map((a) => { + const badge = a.source === "project" ? " (project)" : ""; + const desc = a.description ? ` — ${a.description}` : ""; + const model = a.model ? ` [${a.model}]` : ""; + return `• ${a.name}${badge}${model}${desc}`; + }); + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { agents: list }, + }; + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + const agents = details?.agents ?? []; + if (agents.length === 0) { + return new Text(theme.fg("dim", "No subagent definitions found."), 0, 0); + } + const lines = agents.map((a: any) => { + const badge = a.source === "project" ? theme.fg("accent", " (project)") : ""; + const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : ""; + const model = a.model ? theme.fg("dim", ` [${a.model}]`) : ""; + return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`; + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); + + + + // ── subagent_resume tool ── + if (shouldRegister("subagent_resume")) + pi.registerTool({ + name: "subagent_resume", + label: "Resume Subagent", + description: + "Resume a previous sub-agent session in a new multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + + "Use when a sub-agent was cancelled or needs follow-up work.", + promptSnippet: + "Resume a previous sub-agent session in a new multiplexer pane. " + + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + + "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + + "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + + "Use when a sub-agent was cancelled or needs follow-up work.", + parameters: Type.Object({ + sessionPath: Type.String({ description: "Path to the session .jsonl file to resume" }), + name: Type.Optional( + Type.String({ description: "Display name for the terminal tab. Default: 'Resume'" }), + ), + message: Type.Optional( + Type.String({ + description: "Optional message to send after resuming (e.g. follow-up instructions)", + }), + ), + autoExit: Type.Optional( + Type.Boolean({ + description: + "Whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.", + }), + ), + }), + + renderCall(args, theme) { + const name = args.name ?? "Resume"; + const text = + "▸ " + + theme.fg("toolTitle", theme.bold(name)) + + theme.fg("dim", " — resuming session"); + return new Text(text, 0, 0); + }, + + renderResult(result, _opts, theme) { + const details = result.details as any; + const name = details?.name ?? "Resume"; + + if (details?.status === "started") { + return new Text( + theme.fg("accent", "▸") + + " " + + theme.fg("toolTitle", theme.bold(name)) + + theme.fg("dim", " — resumed"), + 0, + 0, + ); + } + + // Fallback + const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; + return new Text(theme.fg("dim", text), 0, 0); + }, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const name = params.name ?? "Resume"; + const { autoExit, interactive } = resolveResumeLaunchBehavior(params); + const startTime = Date.now(); + const id = Math.random().toString(16).slice(2, 10); + + if (!isMuxAvailable()) { + return muxUnavailableResult(); + } + + if (!existsSync(params.sessionPath)) { + return { + content: [ + { type: "text", text: `Error: session file not found: ${params.sessionPath}` }, + ], + details: { error: "session not found" }, + }; + } + + // Record entry count before resuming so we can extract new messages + const entryCountBefore = getNewEntries(params.sessionPath, 0).length; + + const surface = createSurface(name); + await new Promise((resolve) => setTimeout(resolve, getShellReadyDelayMs())); + + // Build pi resume command + const parts = ["pi", "--session", shellEscape(params.sessionPath)]; + + // Load subagent-done extension so the agent can self-terminate if needed + const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts"); + parts.push("-e", shellEscape(subagentDonePath)); + + const sessionId = ctx.sessionManager.getSessionId(); + const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId); + const activityFile = getSubagentActivityFile(artifactDir, id); + mkdirSync(dirname(activityFile), { recursive: true }); + + let resumeMsgFile: string | undefined; + if (params.message) { + const msgTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + resumeMsgFile = join( + artifactDir, + "subagent-resume", + `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "resume"}-${msgTimestamp}.md`, + ); + mkdirSync(dirname(resumeMsgFile), { recursive: true }); + writeFileSync(resumeMsgFile, params.message, "utf8"); + parts.push(shellEscape(`@${resumeMsgFile}`)); + } + + // Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation + const resumeEnvParts: string[] = []; + if (process.env.PI_CODING_AGENT_DIR) { + resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`); + } + resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellEscape(name)}`); + resumeEnvParts.push(`PI_SUBAGENT_SESSION=${shellEscape(params.sessionPath)}`); + resumeEnvParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`); + resumeEnvParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`); + if (autoExit) { + resumeEnvParts.push(`PI_SUBAGENT_AUTO_EXIT=1`); + } + const resumeEnvPrefix = resumeEnvParts.join(" ") + " "; + + const command = `${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`; + const launchScriptFile = join( + artifactDir, + "subagent-scripts", + `${name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`, + ); + sendLongCommand(surface, command, { + scriptPath: launchScriptFile, + scriptPreamble: [ + `# Subagent resume script for ${name}`, + `# Generated: ${new Date().toISOString()}`, + `# Session: ${params.sessionPath}`, + `# Surface: ${surface}`, + ...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []), + ].join("\n"), + }); + + // Register as a running subagent for widget tracking + const running: RunningSubagent = { + id, + name, + task: params.message ?? "resumed session", + surface, + startTime, + sessionFile: params.sessionPath, + launchScriptFile, + activityFile, + interactive, + statusState: createStatusState({ + source: "pi", + startTimeMs: startTime, + }), + }; + runningSubagents.set(id, running); + startWidgetRefresh(); + startStatusRefresh(pi); + + // Fire-and-forget watcher + const watcherAbort = new AbortController(); + running.abortController = watcherAbort; + + watchSubagent(running, watcherAbort.signal) + .then((result) => { + updateWidget(); + + if (result.ping) { + const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`; + pi.sendMessage( + { + customType: "subagent_ping", + content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`, + display: true, + details: { + name: result.ping.name, + message: result.ping.message, + sessionFile: params.sessionPath, + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + return; + } + + const allEntries = getNewEntries(params.sessionPath, entryCountBefore); + const summary = findLastAssistantMessage(allEntries) ?? + (result.errorMessage + ? `Subagent error: ${result.errorMessage}` + : result.exitCode !== 0 + ? `Resumed session exited with code ${result.exitCode}` + : "Resumed session exited without new output"); + const presentation = resolveResultPresentation( + { ...result, summary, sessionFile: params.sessionPath }, + name, + ); + + pi.sendMessage( + { + customType: "subagent_result", + content: presentation, + display: true, + details: { + name, + task: params.message ?? "resumed session", + exitCode: result.exitCode, + elapsed: result.elapsed, + sessionFile: params.sessionPath, + ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }) + .catch((err) => { + updateWidget(); + pi.sendMessage( + { + customType: "subagent_result", + content: `Resume error: ${err?.message ?? String(err)}`, + display: true, + details: { name, error: err?.message }, + }, + { triggerTurn: true, deliverAs: "steer" }, + ); + }); + + return { + content: [{ type: "text", text: `Session "${name}" resumed.` }], + details: { + id, + name, + sessionPath: params.sessionPath, + launchScriptFile, + status: "started", + }, + }; + }, + }); + + // /iterate command — fork the session into a subagent + pi.registerCommand("iterate", { + description: "Fork session into a subagent for focused work (bugfixes, iteration)", + handler: async (args, _ctx) => { + const task = args.trim() || ""; + const toolCall = task + ? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}` + : `Use subagent to fork a session. fork: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`; + pi.sendUserMessage(toolCall); + }, + }); + + // /subagent command — spawn a subagent by name + pi.registerCommand("subagent", { + description: "Spawn a subagent: /subagent ", + handler: async (args, ctx) => { + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify("Usage: /subagent [task]", "warning"); + return; + } + + const spaceIdx = trimmed.indexOf(" "); + const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + const defs = loadAgentDefaults(agentName); + if (!defs) { + ctx.ui.notify( + `Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`, + "error", + ); + return; + } + + const taskText = task || `You are the ${agentName} agent. Wait for instructions.`; + const displayName = agentName[0].toUpperCase() + agentName.slice(1); + const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`; + pi.sendUserMessage(toolCall); + }, + }); + + // ── subagent_result message renderer ── + pi.registerMessageRenderer("subagent_result", (message, options, theme) => { + const details = message.details as any; + if (!details) return undefined; + + return { + render(width: number): string[] { + const name = details.name ?? "subagent"; + const exitCode = details.exitCode ?? 0; + const errorMessage = typeof details.errorMessage === "string" ? details.errorMessage : ""; + const failed = exitCode !== 0 || !!errorMessage; + const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?"; + const bgFn = failed + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const icon = failed + ? theme.fg("error", "✗") + : theme.fg("success", "✓"); + const status = errorMessage + ? "failed (provider/agent error)" + : failed + ? `failed (exit ${exitCode})` + : "completed"; + const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; + + const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`; + const rawContent = typeof message.content === "string" ? message.content : ""; + + // Clean summary (remove session ref and leading label for display) + const summary = rawContent + .replace(/\n\nSession: .+\nResume: .+$/, "") + .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "") + .replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "") + .replace( + new RegExp( + `^Sub-agent "${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" failed after ${elapsed} \\(provider/agent error — auto-retry exhausted\\)\\.\\n\\n`, + ), + "", + ); + + // Build content for the box + const contentLines = [header]; + + if (options.expanded) { + // Full view: complete summary + session info + if (summary) { + for (const line of summary.split("\n")) { + contentLines.push(line.slice(0, width - 6)); + } + } + if (details.sessionFile) { + contentLines.push(""); + contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`)); + contentLines.push(theme.fg("dim", `Resume: pi --session ${details.sessionFile}`)); + } + } else { + // Collapsed: preview + expand hint + if (summary) { + const previewLines = summary.split("\n").slice(0, 5); + for (const line of previewLines) { + contentLines.push(theme.fg("dim", line.slice(0, width - 6))); + } + const totalLines = summary.split("\n").length; + if (totalLines > 5) { + contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`)); + } + } + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + // Render via Box for background + padding, with blank line above for separation + const box = new Box(1, 1, bgFn); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + + // ── subagent_status message renderer ── + pi.registerMessageRenderer("subagent_status", (message, options, theme) => { + const details = message.details as any; + const lines = Array.isArray(details?.lines) ? details.lines : []; + const overflow = typeof details?.overflow === "number" ? details.overflow : 0; + if (lines.length === 0 && overflow === 0) return undefined; + + return { + render(width: number): string[] { + const lineWidth = Math.max(0, width - 6); + const contentLines = [ + `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`, + ...lines.map((line: string) => theme.fg("dim", truncateToWidth(line, lineWidth))), + ]; + + if (overflow > 0) { + contentLines.push(theme.fg("muted", `+${overflow} more running.`)); + } + if (!options.expanded) { + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text)); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + + // ── subagent_ping message renderer ── + pi.registerMessageRenderer("subagent_ping", (message, options, theme) => { + const details = message.details as any; + if (!details) return undefined; + + return { + render(width: number): string[] { + const name = details.name ?? "subagent"; + const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; + const bgFn = (text: string) => theme.bg("toolSuccessBg", text); + + const icon = theme.fg("accent", "?"); + const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`; + + const contentLines = [header]; + + if (options.expanded) { + contentLines.push(""); + contentLines.push(details.message ?? ""); + if (details.sessionFile) { + contentLines.push(""); + contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`)); + } + } else { + const preview = (details.message ?? "").split("\n")[0].slice(0, width - 10); + contentLines.push(theme.fg("dim", preview)); + contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); + } + + const box = new Box(1, 1, bgFn); + box.addChild(new Text(contentLines.join("\n"), 0, 0)); + return ["", ...box.render(width)]; + }, + }; + }); + + // /plan command — start the full planning workflow + pi.registerCommand("plan", { + description: "Start a planning session: /plan ", + handler: async (args, ctx) => { + const task = args.trim(); + if (!task) { + ctx.ui.notify("Usage: /plan ", "warning"); + return; + } + + // Rename workspace and tab to show this is a planning session + if (isMuxAvailable()) { + try { + const label = task.length > 40 ? task.slice(0, 40) + "..." : task; + renameWorkspace(`🎯 ${label}`); + renameCurrentTab(`🎯 Plan: ${label}`); + } catch { + // non-critical -- do not block the plan + } + } + + // Load the plan skill from the subagents extension directory + const planSkillPath = join(SUBAGENTS_DIR, "plan-skill.md"); + let content = readFileSync(planSkillPath, "utf8"); + content = content.replace(/^---\n[\s\S]*?\n---\n*/, ""); + pi.sendUserMessage( + `\n${content.trim()}\n\n\n${task}`, + ); + }, + }); +} +// test diff --git a/pi-extension/subagents/plan-skill.md b/pi-extension/subagents/plan-skill.md new file mode 100644 index 0000000..91c4594 --- /dev/null +++ b/pi-extension/subagents/plan-skill.md @@ -0,0 +1,203 @@ +--- +name: plan +description: > + Planning workflow. Runs a pre-flight scout, then spawns the planner agent + which clarifies WHAT to build and figures out HOW, with the ability to + spawn its own scouts/researchers mid-session. Use when asked to "plan", + "brainstorm", "I want to build X", or "let's design". Requires the + subagents extension and a supported multiplexer (cmux/tmux/zellij). +--- + +# Plan + +A planning workflow. A scout maps the relevant codebase, then an interactive planner clarifies intent + requirements and designs the technical approach, producing a `plan.md` and todos. + +**Announce at start:** "Let me take a quick look, then I'll send a scout to map the codebase before we start the planning session." + +--- + +## The Flow + +``` +Phase 1: Quick Assessment (main session — 30s orientation) + ↓ +Phase 2: Scout (autonomous — codebase context) + ↓ +Phase 3: Spawn Planner Agent (interactive — clarifies WHAT, plans HOW, creates todos) + ↓ + (Planner may spawn its own scouts/researchers mid-session as needed) + ↓ +Phase 4: Review Plan & Todos (main session) + ↓ +Phase 5: Execute Todos (workers — receive plan + scout context) + ↓ +Phase 6: Review +``` + +--- + +## Phase 1: Quick Assessment + +Quick orientation — just enough to give the scout a focused mission: + +```bash +ls -la +find . -type f -name "*.ts" | head -20 # or relevant extension +cat package.json 2>/dev/null | head -30 +``` + +Spend ~30 seconds. Tech stack, project shape, and the area relevant to the user's request. This tells you what to ask the scout to focus on. + +--- + +## Artifact Paths + +For a planning run, pick a short `` (e.g. `auth-redesign`) and use a shared directory under `.pi/plans/YYYY-MM-DD-/` for every deliverable. Pass explicit paths in each subagent's task and read them back with the plain `read` tool when a subagent finishes. + +Standard filenames: + +- `.pi/plans/YYYY-MM-DD-/scout-context.md` +- `.pi/plans/YYYY-MM-DD-/plan.md` +- `.pi/plans/YYYY-MM-DD-/review.md` (optional, for reviewer output) + +--- + +## Phase 2: Scout + +**Always spawn a scout before the planner.** The scout's context feeds into the planning session — it lets the planner skip re-asking questions whose answers live in the code, and gives it a solid base to design from. + +```typescript +subagent({ + name: "🔍 Scout", + agent: "scout", + task: `Analyze the codebase for [user's request area]. Map file structure, key modules, patterns, conventions, and existing code related to [feature area]. Focus on what a planner would need to understand before designing this feature. + +Save your findings to: .pi/plans/YYYY-MM-DD-/scout-context.md`, +}); +``` + +**Wait for the scout to finish.** Read the scout's context file with the `read` tool — you'll pass it to the planner. + +The planner can spawn **additional** scouts or researchers mid-session if it hits a factual gap. That's expected — don't try to pre-scout every possible area. + +--- + +## Phase 3: Spawn Planner Agent + +Spawn the interactive planner with the scout's context and the user's request. The planner handles everything from here: clarifying intent, compact requirements engineering, ISC, approach exploration, design validation, premortem, plan artifact, and todos. + +```typescript +subagent({ + name: "💬 Planner", + agent: "planner", + interactive: true, + task: `Plan: [what the user wants to build] + +Scout context: +[paste scout findings here — file structure, conventions, patterns, relevant code] + +Save the final plan to: .pi/plans/YYYY-MM-DD-/plan.md +Create todos tagged with: `, +}); +``` + +**The user works with the planner.** It will clarify requirements lightly (1-2 rounds of questions, not a deep spec session), propose approaches, validate the design, run a premortem, write the plan, and create todos with mandatory code examples. + +When done, the user presses Ctrl+D and the plan + todos are returned to the main session. + +### The planner may spawn its own specialists + +During the session, the planner can spawn: +- **`scout`** — when a design decision depends on existing code it hasn't read +- **`researcher`** — when a decision depends on external facts (library tradeoffs, best practices, API behaviors) + +These are internal to the planning session. You'll see them in the multiplexer but don't need to intervene. + +### Optional: extra scout after planning + +If the planner significantly changed scope (new subsystems, areas the original scout didn't cover), spawn another scout targeting the new areas before workers start: + +```typescript +subagent({ + name: "🔍 Scout (updated scope)", + agent: "scout", + task: "The plan changed scope. Gather context for [new areas]. Read the plan at [plan path]. Focus on [specific files/modules the planner identified that weren't in the original scout].", +}); +``` + +Fold the new context into the worker tasks. + +--- + +## Phase 4: Review Plan & Todos + +Once the planner closes, read the plan and list todos: + +```typescript +todo({ action: "list" }); +``` + +Review with the user: + +> "Here's what the planner produced: [brief summary]. Ready to execute, or anything to adjust?" + +--- + +## Phase 5: Execute Todos + +Spawn workers sequentially. Each worker gets the plan path and scout context: + +```typescript +// Workers execute todos sequentially — one at a time +subagent({ + name: "🔨 Worker 1/N", + agent: "worker", + task: "Implement TODO-xxxx. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary from Phase 2, plus any re-scout from Phase 3]", +}); + +// Check result, then next todo +subagent({ + name: "🔨 Worker 2/N", + agent: "worker", + task: "Implement TODO-yyyy. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary]", +}); +``` + +**Always run workers sequentially in the same git repo** — parallel workers will conflict on commits. + +--- + +## Phase 6: Review + +After all todos are complete: + +```typescript +subagent({ + name: "Reviewer", + agent: "reviewer", + interactive: false, + task: "Review the recent changes. Plan: [plan path]", +}); +``` + +Triage findings: + +- **P0** — Real bugs, security issues → fix now +- **P1** — Genuine traps, maintenance dangers → fix before merging +- **P2** — Minor issues → fix if quick, note otherwise +- **P3** — Nits → skip + +Create todos for P0/P1, run workers to fix, re-review only if fixes were substantial. + +--- + +## ⚠️ Completion Checklist + +Before reporting done: + +1. ✅ Scout ran before the planner? +2. ✅ Scout context was passed to the planner? +3. ✅ All worker todos closed? +4. ✅ Every todo has a polished commit (using the `commit` skill)? +5. ✅ Reviewer has run? +6. ✅ Reviewer findings triaged and addressed? diff --git a/pi-extension/subagents/plugin/.claude-plugin/plugin.json b/pi-extension/subagents/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..8ce9b89 --- /dev/null +++ b/pi-extension/subagents/plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "pi-auto-exit", + "version": "1.0.0", + "description": "Auto-exit plugin for pi-spawned Claude sessions" +} diff --git a/pi-extension/subagents/plugin/hooks/hooks.json b/pi-extension/subagents/plugin/hooks/hooks.json new file mode 100644 index 0000000..b2a7135 --- /dev/null +++ b/pi-extension/subagents/plugin/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/pi-extension/subagents/plugin/hooks/on-stop.sh b/pi-extension/subagents/plugin/hooks/on-stop.sh new file mode 100755 index 0000000..4484272 --- /dev/null +++ b/pi-extension/subagents/plugin/hooks/on-stop.sh @@ -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 diff --git a/pi-extension/subagents/session.ts b/pi-extension/subagents/session.ts new file mode 100644 index 0000000..2bcb1f7 --- /dev/null +++ b/pi-extension/subagents/session.ts @@ -0,0 +1,180 @@ +import { appendFileSync, copyFileSync, mkdirSync, readFileSync, 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"); +} + +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; +} + +/** + * Return entries added after `afterLine` (1-indexed count of existing entries). + */ +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; +} diff --git a/pi-extension/subagents/status.ts b/pi-extension/subagents/status.ts new file mode 100644 index 0000000..970dbe6 --- /dev/null +++ b/pi-extension/subagents/status.ts @@ -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 { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + invalidStatusConfig(source, `${fieldName} must be an object`); + } + return value as Record; +} + +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, + 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): 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 { + 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, +): 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")}`; +} diff --git a/pi-extension/subagents/subagent-done.ts b/pi-extension/subagents/subagent-done.ts new file mode 100644 index 0000000..73aaf32 --- /dev/null +++ b/pi-extension/subagents/subagent-done.ts @@ -0,0 +1,324 @@ +/** + * Extension loaded into sub-agents. + * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J) + * - Provides a `subagent_done` tool for autonomous agents to self-terminate + */ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Box, Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { writeFileSync } from "node:fs"; +import { createSubagentActivityRecorder } from "./activity.ts"; + +export function shouldMarkUserTookOver(agentStarted: boolean): boolean { + return agentStarted; +} + +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+J 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+J 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; + + // 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(); + // 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; + recorder.agentStart(); + }); + + pi.on("agent_end", (event, ctx) => { + const messages = (event as any).messages as any[] | undefined; + const shouldExit = 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+J + pi.registerShortcut("ctrl+j", { + description: "Toggle subagent tools widget", + handler: (ctx) => { + expanded = !expanded; + renderWidget(ctx, null); + }, + }); + + pi.registerTool({ + name: "caller_ping", + label: "Caller Ping", + description: + "Send a help request to the parent agent and exit this session. " + + "The parent will be notified with your message and can resume this session with a response. " + + "Use when you're stuck, need clarification, or need the parent to take action.", + parameters: Type.Object({ + message: Type.String({ description: "What you need help with" }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const sessionFile = process.env.PI_SUBAGENT_SESSION; + if (!sessionFile) { + throw new Error( + "caller_ping is only available in subagent contexts. " + + "PI_SUBAGENT_SESSION environment variable is not set.", + ); + } + + recorder.callerPing(); + const exitData = { + type: "ping" as const, + name: process.env.PI_SUBAGENT_NAME ?? "subagent", + message: params.message, + }; + writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData)); + + ctx.shutdown(); + return { + content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }], + details: {}, + }; + }, + }); + + pi.registerTool({ + name: "subagent_done", + label: "Subagent Done", + description: + "Call this tool when you have completed your task. " + + "It will close this session and return your results to the main session. " + + "Your LAST assistant message before calling this becomes the summary returned to the caller.", + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const sessionFile = process.env.PI_SUBAGENT_SESSION; + recorder.subagentDone(); + if (sessionFile) { + writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" })); + } + ctx.shutdown(); + return { + content: [{ type: "text", text: "Shutting down subagent session." }], + details: {}, + }; + }, + }); +} diff --git a/test/integration/agents/test-echo.md b/test/integration/agents/test-echo.md new file mode 100644 index 0000000..ea31510 --- /dev/null +++ b/test/integration/agents/test-echo.md @@ -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. diff --git a/test/integration/agents/test-ping.md b/test/integration/agents/test-ping.md new file mode 100644 index 0000000..d2e0797 --- /dev/null +++ b/test/integration/agents/test-ping.md @@ -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. diff --git a/test/integration/harness.ts b/test/integration/harness.ts new file mode 100644 index 0000000..3d11a51 --- /dev/null +++ b/test/integration/harness.ts @@ -0,0 +1,381 @@ +/** + * Integration test harness for pi-interactive-subagents. + * + * Provides utilities to: + * - Detect available mux backends (cmux, tmux, zellij) + * - Create isolated test environments with test agent definitions + * - Start real pi sessions in mux surfaces + * - Poll for file creation and screen output + * - Clean up surfaces and temp files after tests + */ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + cpSync, + readdirSync, + rmSync, + existsSync, + readFileSync, + unlinkSync, +} 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, + sendEscape, + shellEscape, + parseCmuxFocusedSnapshotFromJson, + parseCmuxPaneRefForSurfaceFromJson, + type MuxBackend, +} from "../../pi-extension/subagents/cmux.ts"; + +// Re-export mux primitives for tests +export { + createSurface, + createSurfaceSplit, + sendCommand, + sendLongCommand, + readScreen, + readScreenAsync, + closeSurface, + sendEscape, + shellEscape, +}; +export type { MuxBackend }; + +// ── 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 ` 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. */ +export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "anthropic/claude-haiku-4-5"; + +/** 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 ── + +/** + * Detect which mux backends are actually available in the current environment. + * Temporarily sets PI_SUBAGENT_MUX to probe each backend. + */ +export function getAvailableBackends(): MuxBackend[] { + const backends: MuxBackend[] = []; + const orig = process.env.PI_SUBAGENT_MUX; + + for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) { + process.env.PI_SUBAGENT_MUX = backend; + try { + if (getMuxBackend() === backend) backends.push(backend); + } catch {} + } + + if (orig === undefined) delete process.env.PI_SUBAGENT_MUX; + else process.env.PI_SUBAGENT_MUX = orig; + + return backends; +} + +export function setBackend(backend: MuxBackend): string | undefined { + const prev = process.env.PI_SUBAGENT_MUX; + process.env.PI_SUBAGENT_MUX = backend; + return prev; +} + +export function restoreBackend(prev: string | undefined): void { + if (prev === undefined) delete process.env.PI_SUBAGENT_MUX; + else process.env.PI_SUBAGENT_MUX = prev; +} + +export function focusSurface(backend: MuxBackend, surface: string): void { + if (backend === "cmux") { + const pane = getSurfacePane(backend, surface); + if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" }); + execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" }); + return; + } + + if (backend === "tmux") { + execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" }); + return; + } + + throw new Error(`Focus helpers are not implemented for ${backend}`); +} + +export function getFocusedSurface(backend: MuxBackend): string | null { + if (backend === "cmux") { + const info = execFileSync("cmux", ["identify", "--json"], { encoding: "utf8" }); + return parseCmuxFocusedSnapshotFromJson(info)?.surfaceRef ?? null; + } + + if (backend === "tmux") { + try { + 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; + } + } + + throw new Error(`Focus helpers are not implemented for ${backend}`); +} + +export function getSurfacePane(backend: MuxBackend, surface: string): string | null { + if (backend === "cmux") { + const info = execFileSync("cmux", ["identify", "--surface", surface], { encoding: "utf8" }); + return parseCmuxPaneRefForSurfaceFromJson(info, surface); + } + + if (backend === "tmux") return surface; + + throw new Error(`Pane lookup is not implemented for ${backend}`); +} + +export async function waitForFocusedSurface( + backend: MuxBackend, + surface: string, + timeout: number = PI_TIMEOUT, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (getFocusedSurface(backend) === surface) return; + await sleep(200); + } + + throw new Error( + `Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` + + `current focus is ${getFocusedSurface(backend) ?? "unknown"}`, + ); +} + +// ── Test environment ── + +export interface TestEnv { + /** Temp directory serving as the test project root */ + dir: string; + /** Active mux backend for this test run */ + backend: MuxBackend; + /** Surfaces 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(backend: MuxBackend): 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")) { + cpSync(join(TEST_AGENTS_SRC, file), join(agentsDir, file)); + } + } + } + + return { dir, backend, 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 ` 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(" "); + + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/test/integration/mux-surface.test.ts b/test/integration/mux-surface.test.ts new file mode 100644 index 0000000..891428b --- /dev/null +++ b/test/integration/mux-surface.test.ts @@ -0,0 +1,243 @@ +/** + * Integration tests for the multiplexer surface layer. + * + * These tests exercise real mux operations: creating panes, + * sending commands, reading screen output, and closing surfaces. + * No LLM calls — fast and free. + * + * Run inside a supported multiplexer: + * cmux bash -c 'npm run test:integration' + * tmux new 'npm run test:integration' + * zellij --session pi # then run: 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, + setBackend, + restoreBackend, + createTestEnv, + cleanupTestEnv, + createTrackedSurface, + createTrackedSurfaceSplit, + focusSurface, + getFocusedSurface, + getSurfacePane, + waitForFocusedSurface, + untrackSurface, + sendCommand, + sendLongCommand, + readScreen, + readScreenAsync, + closeSurface, + sendEscape, + 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("⚠️ No mux backend available — skipping mux-surface integration tests"); + console.log(" Run inside cmux or tmux to enable these tests."); +} + +for (const backend of backends) { + describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => { + let prevMux: string | undefined; + let env: TestEnv; + + before(() => { + prevMux = setBackend(backend); + env = createTestEnv(backend); + }); + + after(() => { + cleanupTestEnv(env); + restoreBackend(prevMux); + }); + + it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => { + const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right"); + await sleep(1000); + + focusSurface(backend, anchor); + await waitForFocusedSurface(backend, anchor, 10_000); + + const childA = createTrackedSurface(env, "focus-child-a"); + await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); + assert.equal(getFocusedSurface(backend), anchor); + + const childB = createTrackedSurface(env, "focus-child-b"); + await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS); + assert.equal(getFocusedSurface(backend), anchor); + + if (backend === "cmux") { + const paneA = getSurfacePane(backend, childA); + const paneB = getSurfacePane(backend, childB); + assert.ok(paneA, `Expected pane ref for ${childA}`); + assert.ok(paneB, `Expected pane ref for ${childB}`); + assert.equal(paneB, paneA); + } + + const markerA = uniqueId(); + const markerB = uniqueId(); + sendCommand(childA, `echo "FOCUS_A_${markerA}"`); + sendCommand(childB, `echo "FOCUS_B_${markerB}"`); + + await Promise.all([ + waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50), + waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50), + ]); + assert.equal(getFocusedSurface(backend), anchor); + }); + + it("creates a surface, sends a command, reads output, and closes it", async () => { + const surface = createTrackedSurface(env, "echo-test"); + await sleep(1000); + + 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); + + 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); + + 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); + + 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(); + sendCommand(s1, `echo "S1_${m1}"`); + 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); + + const marker = uniqueId(); + const filePath = `/tmp/pi-mux-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 {} + }); + + it("delivers Escape as byte 27 to the target surface", async () => { + const surface = createTrackedSurface(env, "escape-byte-test"); + await sleep(1000); + + const marker = uniqueId(); + const byteFile = `/tmp/pi-mux-escape-${marker}.txt`; + trackTempFile(env, byteFile); + + const nodeProgram = + "const fs = require('node:fs');" + + "if (!process.stdin.isTTY) throw new Error('stdin is not a TTY');" + + "process.stdin.setRawMode(true);" + + "process.stdin.resume();" + + "process.stdout.write('ESC_READY\\n');" + + "process.stdin.once('data', (chunk) => {" + + `fs.writeFileSync(${JSON.stringify(byteFile)}, Array.from(chunk).join(','));` + + "process.exit(0);" + + "});"; + const command = `node -e ${JSON.stringify(nodeProgram)}`; + + sendLongCommand(surface, command); + await waitForScreen(surface, /ESC_READY/, 15_000, 50); + + sendEscape(surface); + + const content = await waitForFile(byteFile, 15_000, /^27$/); + assert.equal(content.trim(), "27"); + }); + }); +} diff --git a/test/integration/subagent-lifecycle.test.ts b/test/integration/subagent-lifecycle.test.ts new file mode 100644 index 0000000..128a8d5 --- /dev/null +++ b/test/integration/subagent-lifecycle.test.ts @@ -0,0 +1,332 @@ +/** + * Integration tests for the full subagent lifecycle. + * + * These tests spawn REAL pi sessions with REAL LLM calls (haiku by default). + * Each test creates a mux 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 (haiku). + * Duration: ~30-90s per test. + * + * Run inside a supported multiplexer: + * cmux bash -c 'npm run test:integration' + * tmux new 'npm run test:integration' + * + * Configuration: + * PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5) + * 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, + setBackend, + restoreBackend, + createTestEnv, + cleanupTestEnv, + createTrackedSurface, + startPi, + waitForScreen, + waitForFile, + sleep, + uniqueId, + trackTempFile, + readScreen, + PI_TIMEOUT, + type TestEnv, +} from "./harness.ts"; + +const backends = getAvailableBackends(); + +if (backends.length === 0) { + console.log("⚠️ No mux backend available — skipping subagent lifecycle integration tests"); + console.log(" Run inside cmux or tmux to enable these tests."); +} + +for (const backend of backends) { + describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => { + let prevMux: string | undefined; + let env: TestEnv; + + before(() => { + prevMux = setBackend(backend); + env = createTestEnv(backend); + }); + + after(() => { + cleanupTestEnv(env); + restoreBackend(prevMux); + }); + + // ── 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"); + } + }); + + // ── 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}"`, + ` fork: true`, + ` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`, + `Do not set the agent parameter. Just set name, fork, and task.`, + `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`); + }); + }); +} diff --git a/test/system-prompt-mode.test.ts b/test/system-prompt-mode.test.ts new file mode 100644 index 0000000..6770cb8 --- /dev/null +++ b/test/system-prompt-mode.test.ts @@ -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"); diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 0000000..5503bb8 --- /dev/null +++ b/test/test.ts @@ -0,0 +1,2378 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { visibleWidth } from "@mariozechner/pi-tui"; +import * as subagentsModule from "../pi-extension/subagents/index.ts"; + +import { + getLeafId, + getNewEntries, + findLastAssistantMessage, + appendBranchSummary, + copySessionFile, + mergeNewEntries, + seedSubagentSessionFile, +} from "../pi-extension/subagents/session.ts"; + +import { + shellEscape, + isCmuxAvailable, + isWezTermAvailable, + parseCmuxFocusedSnapshot, + parseCmuxFocusedSnapshotFromJson, + parseCmuxJson, + parseCmuxPaneRefForSurface, + parseCmuxPaneRefForSurfaceFromJson, + canSplitZellijPane, + predictZellijSplitDirection, + selectZellijPlacement, + selectZellijStackPlacement, +} from "../pi-extension/subagents/cmux.ts"; +import { + advanceStatusState, + capStatusLines, + classifyStatus, + createStatusState, + forceStatusAfterInterrupt, + formatStatusAggregate, + formatStatusLine, + formatTransitionLine, + observeStatus, + loadStatusConfig, + parseStatusConfig, +} from "../pi-extension/subagents/status.ts"; +import { + createSubagentActivityRecorder, + getSubagentActivityFile, + readSubagentActivityFile, +} from "../pi-extension/subagents/activity.ts"; +import { + shouldMarkUserTookOver, + shouldAutoExitOnAgentEnd, + findLatestAssistantError, +} from "../pi-extension/subagents/subagent-done.ts"; +import { __pollForExitTest__ } from "../pi-extension/subagents/cmux.ts"; + +// --- Helpers --- + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), "subagents-test-")); +} + +function createSessionFile(dir: string, entries: object[]): string { + const file = join(dir, "test-session.jsonl"); + const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; + writeFileSync(file, content); + return file; +} + +function withTempDir(run: (dir: string) => void) { + const dir = createTestDir(); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function createMockExtensionApi() { + const registeredTools: Array = []; + const registeredCommands: Array = []; + const registeredMessageRenderers: Array = []; + const sentUserMessages: string[] = []; + const sentMessages: Array = []; + return { + registeredTools, + registeredCommands, + registeredMessageRenderers, + sentUserMessages, + sentMessages, + api: { + on() {}, + registerTool(tool: any) { + registeredTools.push(tool); + }, + registerCommand(name: string, command: any) { + registeredCommands.push({ name, ...command }); + }, + registerMessageRenderer(name: string, renderer: any) { + registeredMessageRenderers.push({ name, renderer }); + }, + registerShortcut() {}, + sendUserMessage(message: string) { + sentUserMessages.push(message); + }, + sendMessage(message: any, options?: any) { + sentMessages.push({ message, options }); + }, + getAllTools() { + return []; + }, + } as any, + }; +} + +function restoreEnvVar(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function withMockedNow(now: number, fn: () => T): T { + const originalNow = Date.now; + Date.now = () => now; + try { + return fn(); + } finally { + Date.now = originalNow; + } +} + +function writeAgentFile( + agentsDir: string, + name: string, + frontmatter: string, + body = "You are a test agent.", +) { + mkdirSync(agentsDir, { recursive: true }); + writeFileSync(join(agentsDir, `${name}.md`), `---\n${frontmatter}\n---\n\n${body}\n`); +} + +async function withIsolatedAgentEnv( + fn: (paths: { + projectDir: string; + projectAgentsDir: string; + globalDir: string; + globalAgentsDir: string; + }) => Promise | void, +) { + const root = createTestDir(); + const previousCwd = process.cwd(); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const projectDir = join(root, "project"); + const projectAgentsDir = join(projectDir, ".pi", "agents"); + const globalDir = join(root, "global"); + const globalAgentsDir = join(globalDir, "agents"); + + mkdirSync(projectAgentsDir, { recursive: true }); + mkdirSync(globalAgentsDir, { recursive: true }); + process.chdir(projectDir); + process.env.PI_CODING_AGENT_DIR = globalDir; + + try { + await fn({ projectDir, projectAgentsDir, globalDir, globalAgentsDir }); + } finally { + process.chdir(previousCwd); + restoreEnvVar("PI_CODING_AGENT_DIR", previousAgentDir); + rmSync(root, { recursive: true, force: true }); + } +} +const SESSION_HEADER = { type: "session", id: "sess-001", version: 3 }; +const MODEL_CHANGE = { type: "model_change", id: "mc-001", parentId: null }; +const USER_MSG = { + type: "message", + id: "user-001", + parentId: "mc-001", + message: { + role: "user", + content: [{ type: "text", text: "Hello, plan something" }], + }, +}; +const ASSISTANT_MSG = { + type: "message", + id: "asst-001", + parentId: "user-001", + message: { + role: "assistant", + content: [{ type: "text", text: "Here is my plan..." }], + }, +}; +const ASSISTANT_MSG_2 = { + type: "message", + id: "asst-002", + parentId: "asst-001", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think..." }, + { type: "text", text: "Updated plan with details." }, + ], + }, +}; +const TOOL_RESULT = { + type: "message", + id: "tool-001", + parentId: "asst-001", + message: { + role: "toolResult", + toolCallId: "tc-001", + toolName: "bash", + content: [{ type: "text", text: "output here" }], + }, +}; + +// --- Tests --- + +describe("session.ts", () => { + let dir: string; + + before(() => { + dir = createTestDir(); + }); + + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + describe("getLeafId", () => { + it("returns last entry id", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + assert.equal(getLeafId(file), "asst-001"); + }); + + it("returns null for empty file", () => { + const file = join(dir, "empty.jsonl"); + writeFileSync(file, ""); + assert.equal(getLeafId(file), null); + }); + }); + + describe("getNewEntries", () => { + it("returns entries after a given line", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const entries = getNewEntries(file, 2); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, "user-001"); + assert.equal(entries[1].id, "asst-001"); + }); + + it("returns empty array when no new entries", () => { + const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE]); + const entries = getNewEntries(file, 2); + assert.equal(entries.length, 0); + }); + }); + + describe("findLastAssistantMessage", () => { + it("finds last assistant text", () => { + const entries = [USER_MSG, ASSISTANT_MSG, ASSISTANT_MSG_2] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Updated plan with details."); + }); + + it("skips thinking blocks, gets text only", () => { + const entries = [ASSISTANT_MSG_2] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Updated plan with details."); + }); + + it("skips tool results", () => { + const entries = [ASSISTANT_MSG, TOOL_RESULT] as any[]; + const text = findLastAssistantMessage(entries); + assert.equal(text, "Here is my plan..."); + }); + + it("returns null when no assistant messages", () => { + const entries = [USER_MSG] as any[]; + assert.equal(findLastAssistantMessage(entries), null); + }); + + it("returns null for empty array", () => { + assert.equal(findLastAssistantMessage([]), null); + }); + + it("skips empty assistant messages and returns real content above", () => { + const realMsg = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Real summary content." }], + }, + }; + const emptyMsg = { + type: "message", + message: { + role: "assistant", + content: [], + }, + }; + const entries = [realMsg, emptyMsg] as any[]; + assert.equal(findLastAssistantMessage(entries), "Real summary content."); + }); + + it("surfaces errorMessage when last assistant ended with stopReason=error and no text", () => { + // Reproduces the overload-exhaustion case: an earlier turn looked + // normal, then the provider went 529 and auto-retry gave up. Without + // the errorMessage fallback we'd return the stale earlier summary and + // the orchestrator would believe the subagent completed. + const earlierGood = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Investigating the bug..." }], + }, + }; + const overloadError = { + type: "message", + message: { + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + }; + const entries = [earlierGood, overloadError] as any[]; + assert.equal( + findLastAssistantMessage(entries), + "Subagent error: Anthropic 529 Overloaded after 3 retries", + ); + }); + + it("prefers text content even when an error stopReason is set", () => { + // If the model produced text before the error (rare but possible), we + // prefer the actual content over the synthetic error fallback. + const msg = { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "Here is partial output." }], + stopReason: "error", + errorMessage: "stream interrupted", + }, + }; + assert.equal(findLastAssistantMessage([msg] as any[]), "Here is partial output."); + }); + + it("does not invent a summary for a stop=error message with no errorMessage", () => { + const msg = { + type: "message", + message: { + role: "assistant", + content: [], + stopReason: "error", + }, + }; + assert.equal(findLastAssistantMessage([msg] as any[]), null); + }); + }); + + describe("appendBranchSummary", () => { + it("appends valid branch_summary entry", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG]); + const id = appendBranchSummary(file, "user-001", "asst-001", "The plan was created."); + + assert.ok(id, "should return an id"); + assert.equal(typeof id, "string"); + + // Read back and verify + const lines = readFileSync(file, "utf8").trim().split("\n"); + assert.equal(lines.length, 4); // 3 original + 1 summary + + const summary = JSON.parse(lines[3]); + assert.equal(summary.type, "branch_summary"); + assert.equal(summary.id, id); + assert.equal(summary.parentId, "user-001"); + assert.equal(summary.fromId, "asst-001"); + assert.equal(summary.summary, "The plan was created."); + assert.ok(summary.timestamp); + }); + + it("uses branchPointId as fromId fallback", () => { + const file = createSessionFile(dir, [SESSION_HEADER]); + appendBranchSummary(file, "branch-pt", null, "summary"); + + const lines = readFileSync(file, "utf8").trim().split("\n"); + const summary = JSON.parse(lines[1]); + assert.equal(summary.fromId, "branch-pt"); + }); + }); + + describe("copySessionFile", () => { + it("creates a copy with different path", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG]); + const copyDir = join(dir, "copies"); + mkdirSync(copyDir, { recursive: true }); + const copy = copySessionFile(file, copyDir); + + assert.notEqual(copy, file); + assert.ok(copy.endsWith(".jsonl")); + assert.equal(readFileSync(copy, "utf8"), readFileSync(file, "utf8")); + }); + }); + + describe("seedSubagentSessionFile", () => { + it("creates a lineage-only child session with parent linkage and no copied turns", () => { + const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const childFile = join(dir, "lineage-child.jsonl"); + + seedSubagentSessionFile({ + mode: "lineage-only", + parentSessionFile: parentFile, + childSessionFile: childFile, + childCwd: "/tmp/child-cwd", + }); + + const lines = readFileSync(childFile, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + + const header = JSON.parse(lines[0]); + assert.equal(header.type, "session"); + assert.equal(header.parentSession, parentFile); + assert.equal(header.cwd, "/tmp/child-cwd"); + }); + + it("creates a forked child session with copied context before the triggering user turn", () => { + const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]); + const childFile = join(dir, "fork-child.jsonl"); + + seedSubagentSessionFile({ + mode: "fork", + parentSessionFile: parentFile, + childSessionFile: childFile, + childCwd: "/tmp/fork-child-cwd", + }); + + const entries = readFileSync(childFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "session"); + assert.equal(entries[0].parentSession, parentFile); + assert.equal(entries[0].cwd, "/tmp/fork-child-cwd"); + assert.equal(entries[1].type, "model_change"); + assert.equal(entries.some((entry) => entry.type === "session" && entry.parentSession !== parentFile), false); + assert.equal(entries.some((entry) => entry.type === "message"), false); + }); + }); + + describe("mergeNewEntries", () => { + it("appends new entries from source to target", () => { + // Source starts with same base (2 entries), then has 1 new entry + const sourceFile = join(dir, "merge-source.jsonl"); + const targetFile = join(dir, "merge-target.jsonl"); + writeFileSync( + sourceFile, + [SESSION_HEADER, USER_MSG, ASSISTANT_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + writeFileSync( + targetFile, + [SESSION_HEADER, USER_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + + // Merge entries after line 2 (the shared base) + const merged = mergeNewEntries(sourceFile, targetFile, 2); + assert.equal(merged.length, 1); + assert.equal(merged[0].id, "asst-001"); + + // Target should now have 3 entries + const targetLines = readFileSync(targetFile, "utf8").trim().split("\n"); + assert.equal(targetLines.length, 3); + }); + }); +}); + +describe("status.ts", () => { + it("parses strict config objects", () => { + const disabled = parseStatusConfig({ status: { enabled: false } }); + + assert.deepEqual(disabled, { + enabled: false, + lineLimit: 4, + }); + }); + + it("loads a valid config file", () => { + const examplePath = fileURLToPath(new URL("../config.json.example", import.meta.url)); + const config = loadStatusConfig(examplePath); + + assert.deepEqual(config, { + enabled: true, + lineLimit: 4, + }); + }); + + it("loads the shared example when local config is absent", () => { + withTempDir((dir) => { + const examplePath = join(dir, "config.json.example"); + writeFileSync( + examplePath, + JSON.stringify({ status: { enabled: true } }, null, 2) + "\n", + ); + + const config = loadStatusConfig(join(dir, "config.json"), examplePath); + + assert.deepEqual(config, { + enabled: true, + lineLimit: 4, + }); + }); + }); + + it("fails fast for invalid config shapes", () => { + assert.throws( + () => parseStatusConfig({ status: { enabled: "false" } }), + /status\.enabled must be a boolean/, + ); + assert.throws( + () => parseStatusConfig({ status: { enabled: true, defaultCadenceSeconds: 60 } }), + /status has unsupported key\(s\): defaultCadenceSeconds/, + ); + }); + + it("reports when neither local nor shared config exists", () => { + withTempDir((dir) => { + assert.throws( + () => loadStatusConfig(join(dir, "config.json"), join(dir, "config.json.example")), + /Missing subagent status config\. Expected .*config\.json.*or.*config\.json\.example/, + ); + }); + }); + + it("reports invalid JSON from the shared example path", () => { + withTempDir((dir) => { + const examplePath = join(dir, "config.json.example"); + writeFileSync(examplePath, "{\n"); + + assert.throws( + () => loadStatusConfig(join(dir, "config.json"), examplePath), + /Invalid JSON in subagent config .*config\.json\.example/, + ); + }); + }); + + it("fails on invalid local config instead of falling back to the shared example", () => { + withTempDir((dir) => { + const configPath = join(dir, "config.json"); + const examplePath = join(dir, "config.json.example"); + writeFileSync(configPath, "{\n"); + writeFileSync( + examplePath, + JSON.stringify({ status: { enabled: true } }, null, 2) + "\n", + ); + + assert.throws( + () => loadStatusConfig(configPath, examplePath), + /Invalid JSON in subagent config .*config\.json/, + ); + }); + }); + + it("keeps a missing snapshot as starting until the fixed watchdog threshold", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { snapshot: "missing" }, 1_000); + + assert.equal(classifyStatus(state, 60_999).kind, "starting"); + const stalled = classifyStatus(state, 61_000); + assert.equal(stalled.kind, "stalled"); + assert.equal(stalled.statusLabel, null); + }); + + it("classifies active snapshots without aging into stalled", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + latestEvent: "tool_execution_start", + }, 5_000); + + const snapshot = classifyStatus(state, 240_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.activityLabel, "bash"); + assert.equal(snapshot.activeDurationText, "3m"); + }); + + it("classifies waiting snapshots as healthy idle without becoming stalled", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 1, + phase: "waiting", + waitingSince: 10_000, + latestEvent: "agent_end", + }, 10_000); + + const snapshot = classifyStatus(state, 240_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.waitingDurationText, "3m"); + }); + + it("uses elapsed-only fallback for claude-backed subagents", () => { + const state = createStatusState({ source: "claude", startTimeMs: 0 }); + const snapshot = classifyStatus(state, 125_000); + + assert.equal(snapshot.kind, "running"); + assert.equal(snapshot.elapsedText, "2m"); + }); + + it("detects stalled transitions and recovery", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { snapshot: "missing" }, 1_000); + + let advanced = advanceStatusState(state, 95_000); + assert.equal(advanced.transition, "stalled"); + assert.equal(advanced.snapshot.kind, "stalled"); + + state = observeStatus(advanced.nextState, { + snapshot: "present", + updatedAt: 96_000, + sequence: 1, + phase: "waiting", + waitingSince: 96_000, + latestEvent: "agent_end", + }, 96_000); + advanced = advanceStatusState(state, 97_000); + assert.equal(advanced.transition, "recovered"); + assert.equal(advanced.snapshot.kind, "waiting"); + }); + + it("keeps the last healthy kind during transient snapshot loss", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "streaming", + activeSince: 5_000, + }, 5_000); + state = advanceStatusState(state, 6_000).nextState; + state = observeStatus(state, { snapshot: "missing" }, 10_000); + + const snapshot = classifyStatus(state, 20_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.statusLabel, null); + }); + + it("forces an active state to waiting after interrupt", () => { + const now = 20_000; + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + + assert.equal(classifyStatus(state, now).kind, "active"); + + const forced = forceStatusAfterInterrupt(state, now); + const snapshot = classifyStatus(forced, now); + + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + assert.equal(snapshot.waitingDurationText, "0s"); + assert.equal(forced.activeNow, false); + }); + + it("orders same-millisecond snapshots by sequence", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 10_000, + activityLabel: "bash", + }, 10_000); + + state = observeStatus(state, { + snapshot: "present", + updatedAt: 10_000, + sequence: 3, + phase: "waiting", + waitingSince: 10_000, + latestEvent: "agent_end", + }, 10_001); + + const snapshot = classifyStatus(state, 11_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.latestEvent, "agent_end"); + }); + + it("recovers from a transient snapshot read failure with the same valid snapshot", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + state = observeStatus(state, { snapshot: "missing" }, 10_000); + assert.equal(classifyStatus(state, 10_000).statusLabel, null); + + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 2, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 11_000); + + const snapshot = classifyStatus(state, 11_000); + assert.equal(snapshot.kind, "active"); + assert.equal(snapshot.statusLabel, null); + }); + + it("ignores stale and exact old snapshots after interrupt and accepts newer snapshots", () => { + let state = createStatusState({ source: "pi", startTimeMs: 0 }); + state = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 5_000); + state = forceStatusAfterInterrupt(state, 20_000); + + const stale = observeStatus(state, { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, 21_000); + let snapshot = classifyStatus(stale, 21_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + + const sameTimestamp = observeStatus(stale, { + snapshot: "present", + updatedAt: 20_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 20_000, + activityLabel: "bash", + }, 22_000); + snapshot = classifyStatus(sameTimestamp, 22_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + + const resumed = observeStatus(sameTimestamp, { + snapshot: "present", + sequence: 2, + updatedAt: 25_000, + phase: "active", + active: true, + activeScope: "streaming", + activeSince: 25_000, + activityLabel: "streaming", + }, 25_000); + snapshot = classifyStatus(resumed, 25_000); + assert.equal(snapshot.kind, "active"); + assert.equal(resumed.activeScope, "streaming"); + }); + + it("normalizes and truncates long newline-heavy names", () => { + const longName = `Worker\n\n${"very-long-name-".repeat(12)}`; + const stalledState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { snapshot: "missing" }, + 1_000, + ); + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 299_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 299_000, + activityLabel: "write", + }, + 299_000, + ); + const line = formatStatusLine(longName, classifyStatus(stalledState, 240_000)); + const recovered = formatTransitionLine(longName, classifyStatus(activeState, 300_000), "recovered"); + + assert.doesNotMatch(line, /\n/); + assert.doesNotMatch(recovered, /\n/); + assert.ok(line.length <= 120, `expected bounded line length, got ${line.length}`); + assert.ok(recovered.length <= 120, `expected bounded line length, got ${recovered.length}`); + }); + + it("caps visible status lines and reports overflow consistently", () => { + const waitingState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { snapshot: "present", updatedAt: 180_000, sequence: 1, phase: "waiting", waitingSince: 180_000 }, + 180_000, + ); + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 419_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 419_000, + activityLabel: "bash", + }, + 419_000, + ); + const waitingLine = formatStatusLine("Worker", classifyStatus(waitingState, 300_000)); + const recoveredLine = formatTransitionLine("Worker", classifyStatus(activeState, 420_000), "recovered"); + const lines = [waitingLine, recoveredLine, "Scout running 2m.", "Reviewer running 4m.", "Planner running 6m."]; + const capped = capStatusLines(lines, 3); + const aggregate = formatStatusAggregate(lines, 3); + + assert.equal(waitingLine, "Worker running 5m, waiting 2m."); + assert.equal(recoveredLine, "Worker running 7m, recovered; active (bash 1s)."); + assert.deepEqual(capped.visibleLines, [waitingLine, recoveredLine, "Scout running 2m."]); + assert.equal(capped.overflow, 2); + assert.match(aggregate, /^Subagent status:/); + assert.match(aggregate, /\+2 more running\./); + assert.doesNotMatch(aggregate, /\/tmp|\.jsonl/); + }); +}); + +describe("subagent discovery", () => { + const testApi = (subagentsModule as any).__test__; + + it("loads session-mode from frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "lineage-mode-test-agent", + [ + "name: lineage-mode-test-agent", + "model: anthropic/test-lineage", + "session-mode: lineage-only", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("lineage-mode-test-agent"); + assert.ok(loaded, "expected agent to load"); + assert.equal(loaded.sessionMode, "lineage-only"); + }); + }); + + it("loads explicit interactive flag from frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "interactive-true-test-agent", + [ + "name: interactive-true-test-agent", + "model: anthropic/test-interactive-true", + "interactive: true", + ].join("\n"), + ); + writeAgentFile( + projectAgentsDir, + "interactive-false-test-agent", + [ + "name: interactive-false-test-agent", + "model: anthropic/test-interactive-false", + "interactive: false", + ].join("\n"), + ); + + const loadedTrue = testApi.loadAgentDefaults("interactive-true-test-agent"); + assert.equal(loadedTrue?.interactive, true); + + const loadedFalse = testApi.loadAgentDefaults("interactive-false-test-agent"); + assert.equal(loadedFalse?.interactive, false); + }); + }); + + it("leaves interactive undefined when not set in frontmatter", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "interactive-unset-test-agent", + [ + "name: interactive-unset-test-agent", + "model: anthropic/test-interactive-unset", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("interactive-unset-test-agent"); + assert.equal(loaded?.interactive, undefined); + }); + }); + + it("resolveEffectiveInteractive defaults to the inverse of auto-exit", () => { + // Autonomous agents (auto-exit: true) are NOT interactive — parent gets stall pings. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: true }), + false, + ); + // Agents without auto-exit ARE interactive — parent does not receive status transition pings. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: false }), + true, + ); + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, {}), + true, + ); + // Bare spawn with no agent defs (e.g. /iterate fork) is interactive by default. + assert.equal( + testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, null), + true, + ); + }); + + it("resolveEffectiveInteractive honors explicit frontmatter over the auto-exit default", () => { + // Autonomous agent that still wants to be treated as interactive. + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T" }, + { autoExit: true, interactive: true }, + ), + true, + ); + // Non-auto-exit agent that opts back into stall pings. + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T" }, + { interactive: false }, + ), + false, + ); + }); + + it("resolveEffectiveInteractive honors the explicit tool parameter over all else", () => { + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T", interactive: false }, + { autoExit: false, interactive: true }, + ), + false, + ); + assert.equal( + testApi.resolveEffectiveInteractive( + { name: "A", task: "T", interactive: true }, + { autoExit: true, interactive: false }, + ), + true, + ); + }); + + it("bundled scout/worker/reviewer agents resolve as non-interactive; planner resolves as interactive", () => { + for (const name of ["scout", "worker", "reviewer"]) { + const defs = testApi.loadAgentDefaults(name); + assert.ok(defs, `expected bundled agent ${name} to be discoverable`); + assert.equal( + testApi.resolveEffectiveInteractive({ name, task: "" }, defs), + false, + `${name} should resolve as non-interactive (autonomous)`, + ); + } + + const planner = testApi.loadAgentDefaults("planner"); + assert.ok(planner, "expected bundled planner to be discoverable"); + assert.equal( + testApi.resolveEffectiveInteractive({ name: "planner", task: "" }, planner), + true, + "planner should resolve as interactive (no auto-exit)", + ); + }); + + it("ignores invalid session-mode values", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "invalid-mode-test-agent", + [ + "name: invalid-mode-test-agent", + "model: anthropic/test-invalid", + "session-mode: sideways", + ].join("\n"), + ); + + const loaded = testApi.loadAgentDefaults("invalid-mode-test-agent"); + assert.ok(loaded, "expected agent to load"); + assert.equal(loaded.sessionMode, undefined); + }); + }); + + it("resolves session mode with fork override precedence", () => { + assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone"); + assert.equal( + testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "lineage-only" }), + "lineage-only", + ); + assert.equal( + testApi.resolveEffectiveSessionMode( + { name: "A", task: "T", fork: true }, + { sessionMode: "lineage-only" }, + ), + "fork", + ); + }); + + it("resolves launch behavior for standalone, lineage-only, and fork modes", () => { + assert.deepEqual(testApi.resolveLaunchBehavior({ name: "A", task: "T" }, null), { + sessionMode: "standalone", + seededSessionMode: null, + inheritsConversationContext: false, + taskDelivery: "artifact", + }); + assert.deepEqual( + testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "lineage-only" }), + { + sessionMode: "lineage-only", + seededSessionMode: "lineage-only", + inheritsConversationContext: false, + taskDelivery: "artifact", + }, + ); + assert.deepEqual( + testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "fork" }), + { + sessionMode: "fork", + seededSessionMode: "fork", + inheritsConversationContext: true, + taskDelivery: "direct", + }, + ); + assert.deepEqual( + testApi.resolveLaunchBehavior( + { name: "A", task: "T", fork: true }, + { sessionMode: "lineage-only" }, + ), + { + sessionMode: "fork", + seededSessionMode: "fork", + inheritsConversationContext: true, + taskDelivery: "direct", + }, + ); + }); + + it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => { + assert.equal( + testApi.buildSubagentToolAllowlist("read,bash,web_search"), + "read,bash,web_search,caller_ping,subagent_done", + ); + }); + + it("buildSubagentToolAllowlist returns null without an explicit tool restriction", () => { + assert.equal(testApi.buildSubagentToolAllowlist(undefined), null); + assert.equal(testApi.buildSubagentToolAllowlist(""), null); + }); + + it("buildPiPromptArgs inserts separator for artifact-backed launches with skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: "review,lint", taskDelivery: "artifact", taskArg: "@artifact.md" }), + ["", "/skill:review", "/skill:lint", "@artifact.md"], + ); + }); + + it("buildPiPromptArgs omits separator for artifact-backed launches without skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: undefined, taskDelivery: "artifact", taskArg: "@artifact.md" }), + ["@artifact.md"], + ); + }); + + it("buildPiPromptArgs omits separator for direct launches with skills", () => { + assert.deepEqual( + testApi.buildPiPromptArgs({ effectiveSkills: "review", taskDelivery: "direct", taskArg: "do the task" }), + ["/skill:review", "do the task"], + ); + }); + + it("lists visible agents from discovery", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "visible-discovery-test-agent", + [ + "name: visible-discovery-test-agent", + "description: Visible test agent", + "model: anthropic/test-visible", + ].join("\n"), + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.ok(agents.some((agent: any) => agent.name === "visible-discovery-test-agent")); + assert.match(result.content[0].text, /visible-discovery-test-agent/); + }); + }); + + it("hides disable-model-invocation agents from listings but keeps direct loading", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { + writeAgentFile( + projectAgentsDir, + "hidden-discovery-test-agent", + [ + "name: hidden-discovery-test-agent", + "description: Hidden test agent", + "model: anthropic/test-hidden", + "disable-model-invocation: true", + ].join("\n"), + "You are the hidden agent.", + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.equal(agents.some((agent: any) => agent.name === "hidden-discovery-test-agent"), false); + assert.doesNotMatch(result.content[0].text, /hidden-discovery-test-agent/); + + const loaded = testApi.loadAgentDefaults("hidden-discovery-test-agent"); + assert.ok(loaded, "expected hidden agent to remain directly loadable"); + assert.equal(loaded.model, "anthropic/test-hidden"); + assert.equal(loaded.body, "You are the hidden agent."); + assert.equal(loaded.disableModelInvocation, true); + }); + }); + + it("lets a hidden project agent shadow a visible global agent", async () => { + await withIsolatedAgentEnv(async ({ projectAgentsDir, globalAgentsDir }) => { + writeAgentFile( + globalAgentsDir, + "shadowed-discovery-test-agent", + [ + "name: shadowed-discovery-test-agent", + "description: Global visible agent", + "model: anthropic/test-global", + ].join("\n"), + "You are the global visible agent.", + ); + writeAgentFile( + projectAgentsDir, + "shadowed-discovery-test-agent", + [ + "name: shadowed-discovery-test-agent", + "description: Project hidden agent", + "model: anthropic/test-project", + "disable-model-invocation: true", + ].join("\n"), + "You are the project hidden agent.", + ); + + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const tool = registeredTools.find((tool) => tool.name === "subagents_list"); + assert.ok(tool, "expected subagents_list to be registered"); + + const result = await tool.execute(); + const agents = result.details?.agents ?? []; + + assert.equal(agents.some((agent: any) => agent.name === "shadowed-discovery-test-agent"), false); + assert.doesNotMatch(result.content[0].text, /shadowed-discovery-test-agent/); + + const loaded = testApi.loadAgentDefaults("shadowed-discovery-test-agent"); + assert.ok(loaded, "expected project override to remain directly loadable"); + assert.equal(loaded.model, "anthropic/test-project"); + assert.equal(loaded.body, "You are the project hidden agent."); + assert.equal(loaded.disableModelInvocation, true); + }); + }); +}); +describe("subagent-done.ts", () => { + describe("shouldMarkUserTookOver", () => { + it("ignores the initial injected task before the first agent run", () => { + assert.equal(shouldMarkUserTookOver(false), false); + }); + + it("treats later input as manual takeover", () => { + assert.equal(shouldMarkUserTookOver(true), true); + }); + }); + + describe("shouldAutoExitOnAgentEnd", () => { + it("auto-exits after normal completion when there was no takeover", () => { + const messages = [{ role: "assistant", stopReason: "stop" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), true); + }); + + it("auto-exits after normal completion even when the user sent the prompt", () => { + const messages = [{ role: "assistant", stopReason: "stop" }]; + assert.equal(shouldAutoExitOnAgentEnd(true, messages), true); + }); + + it("stays open after Escape aborts the run", () => { + const messages = [{ role: "assistant", stopReason: "aborted" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), false); + }); + + it("still exits when the latest turn ended with stopReason=error", () => { + // Auto-exit subagents must shut down on retry-exhaustion errors so the + // parent is woken. The error sidecar (written separately) carries the + // failure detail; staying open would just strand the worker. + const messages = [{ role: "assistant", stopReason: "error", errorMessage: "529 overloaded" }]; + assert.equal(shouldAutoExitOnAgentEnd(false, messages), true); + }); + }); + + describe("findLatestAssistantError", () => { + it("returns the error info from a stopReason=error message", () => { + const messages = [ + { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "ok" }] }, + { role: "toolResult", content: [] }, + { role: "assistant", stopReason: "error", errorMessage: "Anthropic 529 Overloaded" }, + ]; + assert.deepEqual(findLatestAssistantError(messages), { + errorMessage: "Anthropic 529 Overloaded", + stopReason: "error", + }); + }); + + it("returns null when the latest assistant turn completed normally", () => { + const messages = [ + { role: "assistant", stopReason: "error", errorMessage: "old failure" }, + { role: "user", content: [] }, + { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "done" }] }, + ]; + assert.equal(findLatestAssistantError(messages), null); + }); + + it("returns null when the latest assistant turn was aborted by the user", () => { + const messages = [{ role: "assistant", stopReason: "aborted" }]; + assert.equal(findLatestAssistantError(messages), null); + }); + + it("falls back to a placeholder when stopReason=error has no errorMessage field", () => { + const messages = [{ role: "assistant", stopReason: "error" }]; + const info = findLatestAssistantError(messages); + assert.ok(info); + assert.equal(info!.stopReason, "error"); + assert.match(info!.errorMessage, /stopReason=error/); + }); + + it("returns null when messages is undefined or empty", () => { + assert.equal(findLatestAssistantError(undefined), null); + assert.equal(findLatestAssistantError([]), null); + }); + }); +}); + +describe("cmux.ts interpretExitSidecar", () => { + const { interpretExitSidecar } = __pollForExitTest__; + + it("decodes ping payloads", () => { + assert.deepEqual( + interpretExitSidecar({ type: "ping", name: "Worker", message: "need help" }), + { + reason: "ping", + exitCode: 0, + ping: { name: "Worker", message: "need help" }, + }, + ); + }); + + it("decodes done payloads", () => { + assert.deepEqual(interpretExitSidecar({ type: "done" }), { + reason: "done", + exitCode: 0, + }); + }); + + it("decodes error payloads and propagates the message with a non-zero exit code", () => { + assert.deepEqual( + interpretExitSidecar({ + type: "error", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + stopReason: "error", + }), + { + reason: "error", + exitCode: 1, + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + ); + }); + + it("falls back to a placeholder when error payload has no errorMessage", () => { + const result = interpretExitSidecar({ type: "error" }); + assert.equal(result.reason, "error"); + assert.equal(result.exitCode, 1); + assert.match(result.errorMessage ?? "", /no errorMessage/); + }); + + it("treats unknown payload shapes as done", () => { + assert.deepEqual(interpretExitSidecar({}), { reason: "done", exitCode: 0 }); + assert.deepEqual(interpretExitSidecar(null), { reason: "done", exitCode: 0 }); + }); +}); +describe("commands", () => { + it("/iterate always emits a full-context fork tool call", () => { + const { api, registeredCommands, sentUserMessages } = createMockExtensionApi(); + + (subagentsModule as any).default(api); + + const iterate = registeredCommands.find((command) => command.name === "iterate"); + assert.ok(iterate, "expected /iterate to be registered"); + + iterate.handler("Fix the bug", {}); + + assert.equal(sentUserMessages.length, 1); + assert.match(sentUserMessages[0], /fork: true/); + assert.match(sentUserMessages[0], /name: "Iterate"/); + }); +}); + +describe("tool registration", () => { + it("defaults resumed subagents to auto-exit and non-interactive tracking", () => { + const testApi = (subagentsModule as any).__test__; + + assert.deepEqual(testApi.resolveResumeLaunchBehavior({}), { + autoExit: true, + interactive: false, + }); + assert.deepEqual(testApi.resolveResumeLaunchBehavior({ autoExit: false }), { + autoExit: false, + interactive: true, + }); + }); + + it("expands spawning false to deny subagent interruption", () => { + const testApi = (subagentsModule as any).__test__; + const denied = testApi.resolveDenyTools({ spawning: false }); + + assert.equal(denied.has("subagent"), true); + assert.equal(denied.has("subagent_interrupt"), true); + assert.equal(denied.has("subagent_resume"), true); + }); + + it("renders partial subagent tool-call args without throwing", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const subagentTool = registeredTools.find((tool) => tool.name === "subagent"); + assert.ok(subagentTool, "expected subagent tool to be registered"); + + const theme = { + fg(_color: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, + }; + const rendered = subagentTool.renderCall({}, theme); + const output = rendered.render(80).join("\n"); + + assert.match(output, /\(unnamed\)/); + }); + + it("registers subagent_resume with an autoExit override", () => { + const { api, registeredTools } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const resumeTool = registeredTools.find((tool) => tool.name === "subagent_resume"); + assert.ok(resumeTool, "expected subagent_resume tool to be registered"); + + const autoExitSchema = resumeTool.parameters.properties.autoExit; + assert.equal(autoExitSchema.type, "boolean"); + assert.match(autoExitSchema.description, /Defaults to true/); + }); +}); + +describe("subagent activity snapshots", () => { + function validActivity(overrides: Record = {}) { + return { + version: 1, + runningChildId: "child-1", + createdAt: 1_000, + updatedAt: 1_000, + sequence: 1, + latestEvent: "session_start", + phase: "starting", + agentActive: false, + turnActive: false, + providerActive: false, + toolActive: false, + ...overrides, + }; + } + + it("writes and validates activity files by running child id", () => { + withTempDir((dir) => { + const activityFile = getSubagentActivityFile(dir, "child-1"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-1", + activityFile, + now: () => 1_000, + }); + + recorder.sessionStart(); + recorder.toolExecutionStart("tool-1", "bash"); + + const read = readSubagentActivityFile(activityFile, "child-1"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "active"); + assert.equal(read.activity.activeScope, "tool"); + assert.equal(read.activity.toolName, "bash"); + + assert.deepEqual(readSubagentActivityFile(activityFile, "other-child"), { + ok: false, + reason: "wrong-id", + }); + }); + }); + + it("records waiting and final done states", () => { + withTempDir((dir) => { + let currentNow = 2_000; + const activityFile = getSubagentActivityFile(dir, "child-2"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-2", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + currentNow = 3_000; + recorder.agentEndWaiting(); + let read = readSubagentActivityFile(activityFile, "child-2"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "waiting"); + assert.equal(read.activity.waitingSince, 3_000); + + currentNow = 4_000; + recorder.subagentDone(); + read = readSubagentActivityFile(activityFile, "child-2"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "done"); + assert.equal(read.activity.agentActive, false); + }); + }); + + it("rejects malformed activity fields used by classification and rendering", () => { + withTempDir((dir) => { + mkdirSync(join(dir, "subagent-activity"), { recursive: true }); + const cases = [ + { activeSince: "bad" }, + { waitingSince: "bad" }, + { activeScope: "database" }, + { latestEvent: "unknown" }, + { runningChildId: 42 }, + { toolActive: "yes" }, + { toolName: "bad\nname" }, + ]; + + for (const [index, overrides] of cases.entries()) { + const activityFile = getSubagentActivityFile(dir, `child-${index}`); + const activity = validActivity({ runningChildId: `child-${index}`, ...overrides }); + writeFileSync(activityFile, `${JSON.stringify(activity)}\n`); + + const read = readSubagentActivityFile(activityFile, `child-${index}`); + assert.equal(read.ok, false); + assert.equal((read as { ok: false; reason: string }).reason, "invalid"); + } + }); + }); + + it("does not let tool_result resurrect finished tool activity", () => { + withTempDir((dir) => { + let currentNow = 1_000; + const activityFile = getSubagentActivityFile(dir, "child-3"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-3", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + recorder.agentStart(); + recorder.turnStart(1); + currentNow = 2_000; + recorder.toolExecutionStart("tool-1", "bash"); + currentNow = 3_000; + recorder.toolExecutionEnd("tool-1", "bash"); + currentNow = 4_000; + recorder.toolResult("tool-1", "bash"); + + const read = readSubagentActivityFile(activityFile, "child-3"); + assert.ok(read.ok); + assert.equal(read.activity.toolActive, false); + assert.equal(read.activity.activeScope, "turn"); + }); + }); + + it("does not mark reload shutdown as the final done snapshot", () => { + withTempDir((dir) => { + const activityFile = getSubagentActivityFile(dir, "child-4"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-4", + activityFile, + now: () => 1_000, + }); + + recorder.sessionStart(); + recorder.sessionShutdown("reload"); + + const read = readSubagentActivityFile(activityFile, "child-4"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "starting"); + assert.equal(read.activity.latestEvent, "session_start"); + }); + }); + + it("cancels pending throttled writes on reload shutdown", async () => { + const dir = createTestDir(); + try { + await new Promise((resolve) => { + let currentNow = 1_000; + const activityFile = getSubagentActivityFile(dir, "child-5"); + const recorder = createSubagentActivityRecorder({ + runningChildId: "child-5", + activityFile, + now: () => currentNow, + }); + + recorder.sessionStart(); + currentNow = 1_100; + recorder.messageUpdate("delta"); + recorder.sessionShutdown("reload"); + + setTimeout(() => { + const read = readSubagentActivityFile(activityFile, "child-5"); + assert.ok(read.ok); + assert.equal(read.activity.phase, "starting"); + assert.equal(read.activity.latestEvent, "session_start"); + resolve(); + }, 650); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("subagent interruption", () => { + function makeRunning(overrides: Record = {}) { + return { + id: "a1", + name: "Worker", + task: "", + surface: "pane-1", + startTime: 0, + sessionFile: "worker.jsonl", + interactive: false, + statusState: createStatusState({ source: "pi", startTimeMs: 0 }), + ...overrides, + }; + } + + it("registers subagent_interrupt in the main session extension", () => { + const { api, registeredTools } = createMockExtensionApi(); + + (subagentsModule as any).default(api); + + assert.equal(registeredTools.some((tool) => tool.name === "subagent_interrupt"), true); + }); + + it("resolves interrupt targets by exact id and reports name ambiguity", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + + try { + runningMap.set("a1", makeRunning({ id: "a1", name: "Worker", surface: "a1", sessionFile: "a1.jsonl" })); + runningMap.set("b2", makeRunning({ id: "b2", name: "Worker", surface: "b2", sessionFile: "b2.jsonl" })); + runningMap.set("c3", makeRunning({ id: "c3", name: "Scout", surface: "c3", sessionFile: "c3.jsonl" })); + + const byId = testApi.resolveInterruptTarget({ id: "c3", name: "Worker" }); + assert.equal(byId.running.id, "c3"); + + const ambiguous = testApi.resolveInterruptTarget({ name: "Worker" }); + assert.match(ambiguous.error, /Ambiguous subagent name/); + } finally { + runningMap.clear(); + } + }); + + it("returns an explicit error when Escape delivery fails", () => { + const testApi = (subagentsModule as any).__test__; + let aborted = false; + const running = makeRunning({ + abortController: { + abort() { + aborted = true; + }, + }, + }); + + const result = testApi.requestSubagentInterrupt(running, () => { + throw new Error("mux write failed"); + }); + + assert.match(result.error, /Failed to send Escape/); + assert.equal(aborted, false); + assert.equal("interruptRequested" in running, false); + }); + + it("leaves status unchanged when Escape delivery fails in the tool path", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + runningMap.clear(); + + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, + 5_000, + ); + + try { + runningMap.set("a1", makeRunning({ statusState: activeState })); + + const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, () => { + throw new Error("mux write failed"); + })); + + assert.match(result.content[0].text, /Failed to send Escape/); + assert.equal(classifyStatus(runningMap.get("a1").statusState, 20_000).kind, "active"); + } finally { + runningMap.clear(); + } + }); + + it("sends Escape without aborting or mutating running state", () => { + const testApi = (subagentsModule as any).__test__; + let aborted = false; + let sentSurface = ""; + const running = makeRunning({ + abortController: { + abort() { + aborted = true; + }, + }, + }); + + const result = testApi.requestSubagentInterrupt(running, (surface: string) => { + sentSurface = surface; + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(sentSurface, "pane-1"); + assert.equal(aborted, false); + assert.equal("interruptRequested" in running, false); + }); + + it("refreshes the latest activity snapshot before forcing local interrupt waiting", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + let sentSurface = ""; + runningMap.clear(); + + withTempDir((dir) => { + mkdirSync(join(dir, "subagent-activity"), { recursive: true }); + const activityFile = getSubagentActivityFile(dir, "a1"); + const activity = { + version: 1, + runningChildId: "a1", + createdAt: 1_000, + updatedAt: 19_000, + sequence: 7, + latestEvent: "tool_execution_start", + phase: "active", + agentActive: true, + turnActive: true, + providerActive: false, + toolActive: true, + activeScope: "tool", + activeSince: 19_000, + toolName: "bash", + }; + writeFileSync(activityFile, `${JSON.stringify(activity)}\n`); + + try { + runningMap.set("a1", makeRunning({ + activityFile, + statusState: createStatusState({ source: "pi", startTimeMs: 0 }), + })); + + withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { + sentSurface = surface; + })); + + assert.equal(sentSurface, "pane-1"); + const state = runningMap.get("a1").statusState; + const snapshot = classifyStatus(state, 20_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + assert.equal(state.lastActivityAtMs, 20_000); + assert.equal(state.lastActivitySequence, 7); + assert.equal(state.localOverrideSequence, 7); + } finally { + runningMap.clear(); + } + }); + }); + + it("acknowledges Pi-backed interrupt requests and forces local status waiting", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + let sentSurface = ""; + runningMap.clear(); + + const activeState = observeStatus( + createStatusState({ source: "pi", startTimeMs: 0 }), + { + snapshot: "present", + updatedAt: 5_000, + sequence: 1, + phase: "active", + active: true, + activeScope: "tool", + activeSince: 5_000, + activityLabel: "bash", + }, + 5_000, + ); + + try { + runningMap.set("a1", makeRunning({ statusState: activeState })); + + const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { + sentSurface = surface; + })); + + assert.equal(sentSurface, "pane-1"); + assert.equal(result.content[0].text, 'Interrupt requested for subagent "Worker".'); + assert.deepEqual(result.details, { id: "a1", name: "Worker", status: "interrupt_requested" }); + const snapshot = classifyStatus(runningMap.get("a1").statusState, 20_000); + assert.equal(snapshot.kind, "waiting"); + assert.equal(snapshot.activityLabel, "interrupted"); + assert.equal(runningMap.has("a1"), true); + } finally { + runningMap.clear(); + } + }); + + it("sends Escape again for repeated interrupt requests", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + const surfaces: string[] = []; + runningMap.clear(); + + try { + runningMap.set("a1", makeRunning()); + + testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { + surfaces.push(surface); + }); + testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => { + surfaces.push(surface); + }); + + assert.deepEqual(surfaces, ["pane-1", "pane-1"]); + assert.equal(runningMap.has("a1"), true); + } finally { + runningMap.clear(); + } + }); + + it("rejects Claude-backed interrupt requests before delivery", () => { + const testApi = (subagentsModule as any).__test__; + const runningMap = testApi.runningSubagents as Map; + let delivered = false; + runningMap.clear(); + + try { + runningMap.set("a1", makeRunning({ cli: "claude" })); + + const result = testApi.handleSubagentInterrupt({ name: "Worker" }, () => { + delivered = true; + }); + + assert.equal(delivered, false); + assert.match(result.content[0].text, /currently supported only for Pi-backed subagents/i); + assert.deepEqual(result.details, { + error: "claude interrupt unsupported", + id: "a1", + name: "Worker", + }); + } finally { + runningMap.clear(); + } + }); + + it("formats exit code 130 as an ordinary failure", () => { + const testApi = (subagentsModule as any).__test__; + const presentation = testApi.resolveResultPresentation( + { + exitCode: 130, + elapsed: 61, + summary: "Sub-agent exited with code 130", + sessionFile: "/tmp/subagent.jsonl", + }, + "Worker", + ); + + assert.match(presentation, /failed \(exit code 130\)/); + assert.doesNotMatch(presentation, /interrupted/); + assert.match(presentation, /Resume: pi --session/); + }); + + it("renders a clear provider/agent error when errorMessage is set", () => { + // Previously, an overload retry-exhaustion produced exitCode 0 with a + // stale summary — the orchestrator thought the subagent finished + // quickly. With the error sidecar plumbed through, the presentation + // must call out the failure, include the underlying error, and tell the + // orchestrator how to recover. + const testApi = (subagentsModule as any).__test__; + const presentation = testApi.resolveResultPresentation( + { + exitCode: 1, + elapsed: 14, + summary: "ignored when errorMessage is present", + sessionFile: "/tmp/subagent.jsonl", + errorMessage: "Anthropic 529 Overloaded after 3 retries", + }, + "Worker", + ); + + assert.match(presentation, /Sub-agent "Worker" failed/); + assert.match(presentation, /provider\/agent error — auto-retry exhausted/); + assert.match(presentation, /Error: Anthropic 529 Overloaded after 3 retries/); + assert.match(presentation, /subagent_resume/); + assert.match(presentation, /Resume: pi --session/); + assert.doesNotMatch(presentation, /ignored when errorMessage is present/); + }); +}); + +describe("subagent status renderer", () => { + function createTheme() { + return { + fg(_color: string, text: string) { + return text; + }, + bg(_color: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, + }; + } + + it("renders only capped lines plus overflow", () => { + const { api, registeredMessageRenderers } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status"); + assert.ok(rendererEntry, "expected subagent_status renderer to be registered"); + + const visibleLines = [ + "Worker running 5m, active (bash 2m).", + "Scout running 3m, waiting 1m.", + "Reviewer running 2m, active (streaming 30s).", + "Planner running 4m, waiting 2m.", + ]; + const rendered = rendererEntry.renderer( + { + customType: "subagent_status", + content: "Subagent status:\n• Worker running 5m, active (bash 2m).", + details: { + lines: visibleLines, + overflow: 2, + }, + }, + { expanded: true }, + createTheme(), + ); + const output = rendered.render(80).join("\n"); + + assert.match(output, /Subagent status/); + for (const line of visibleLines) { + assert.match(output, new RegExp(line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.match(output, /\+2 more running\./); + }); + + it("stays within narrow widths", () => { + const { api, registeredMessageRenderers } = createMockExtensionApi(); + (subagentsModule as any).default(api); + + const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status"); + assert.ok(rendererEntry, "expected subagent_status renderer to be registered"); + + const rendered = rendererEntry.renderer( + { + customType: "subagent_status", + content: "Subagent status:\n• Worker running 5m, active (bash 2m).", + details: { lines: ["Worker running 5m, active (bash 2m)."], overflow: 0 }, + }, + { expanded: true }, + createTheme(), + ); + + for (const width of [4, 5, 6]) { + for (const line of rendered.render(width)) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`, + ); + } + } + }); +}); + +describe("subagent startup delay", () => { + it("defaults to 500ms when no env var is set", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.getShellReadyDelayMs, "function"); + + const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + try { + assert.equal(testApi.getShellReadyDelayMs(), 500); + } finally { + if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original; + } + }); + + it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.getShellReadyDelayMs, "function"); + + const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = "2500"; + try { + assert.equal(testApi.getShellReadyDelayMs(), 2500); + } finally { + if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS; + else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original; + } + }); +}); +describe("subagents widget rendering", () => { + it("keeps every rendered line within a very narrow width", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.renderSubagentWidgetLines, "function"); + + const originalNow = Date.now; + Date.now = () => 1_000_000; + try { + const lines = testApi.renderSubagentWidgetLines([ + { + id: "a1", + name: "A", + task: "", + surface: "s1", + startTime: 1_000_000 - 13_000, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 13_000 }), + }, + { + id: "a2", + name: "B", + task: "", + surface: "s2", + startTime: 1_000_000 - 21_000, + sessionFile: "sess2", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 21_000 }), + }, + { + id: "a3", + name: "C", + task: "", + surface: "s3", + startTime: 1_000_000 - 27_000, + sessionFile: "sess3", + statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 27_000 }), + }, + ], 16); + + assert.deepEqual( + lines.map((line: string) => visibleWidth(line)), + [16, 16, 16, 16, 16], + ); + } finally { + Date.now = originalNow; + } + }); + + it("truncates the right-hand status instead of overflowing when it alone is too wide", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.borderLine, "function"); + + const line = testApi.borderLine(" A ", " 999 msgs (999.9KB) ", 16); + assert.equal(visibleWidth(line), 16); + }); + + it("handles ultra-narrow widths without exceeding the width contract", () => { + const testApi = (subagentsModule as any).__test__; + assert.ok(testApi, "expected subagents test helpers to be exported"); + assert.equal(typeof testApi.renderSubagentWidgetLines, "function"); + + const widths = [0, 1, 2]; + for (const width of widths) { + const startTime = Date.now() - 5_000; + const lines = testApi.renderSubagentWidgetLines([ + { + id: "a1", + name: "A", + task: "", + surface: "s1", + startTime, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }, + ], width); + + for (const line of lines) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`, + ); + } + } + }); +}); + +describe("cmux.ts", () => { + describe("shellEscape", () => { + it("wraps in single quotes", () => { + assert.equal(shellEscape("hello"), "'hello'"); + }); + + it("escapes single quotes", () => { + assert.equal(shellEscape("it's"), "'it'\\''s'"); + }); + + it("handles empty string", () => { + assert.equal(shellEscape(""), "''"); + }); + + it("handles special characters", () => { + const input = 'echo "hello $world" && rm -rf /'; + const escaped = shellEscape(input); + assert.ok(escaped.startsWith("'")); + assert.ok(escaped.endsWith("'")); + // Inside single quotes, everything is literal + assert.ok(escaped.includes("$world")); + }); + }); + + describe("parseCmuxFocusedSnapshot", () => { + it("parses focused surface and pane refs", () => { + assert.deepEqual( + parseCmuxFocusedSnapshot({ focused: { surface_ref: "surface:3", pane_ref: "pane:2" } }), + { surfaceRef: "surface:3", paneRef: "pane:2" }, + ); + }); + + it("does not fall back to caller refs", () => { + assert.equal( + parseCmuxFocusedSnapshot({ caller: { surface_ref: "surface:1", pane_ref: "pane:1" } }), + null, + ); + }); + + it("returns null for malformed values", () => { + assert.equal(parseCmuxFocusedSnapshot(null), null); + assert.equal(parseCmuxFocusedSnapshot({ focused: {} }), null); + }); + }); + + describe("parseCmuxJson", () => { + it("returns null for malformed JSON text", () => { + assert.equal(parseCmuxJson("not json"), null); + }); + + it("parses valid JSON text", () => { + assert.deepEqual(parseCmuxJson('{"ok":true}'), { ok: true }); + }); + }); + + describe("parseCmuxFocusedSnapshotFromJson", () => { + it("returns null for malformed JSON text", () => { + assert.equal(parseCmuxFocusedSnapshotFromJson("not json"), null); + }); + + it("returns null when focused is absent or not an object", () => { + assert.equal( + parseCmuxFocusedSnapshotFromJson('{"focused":null,"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'), + null, + ); + assert.equal( + parseCmuxFocusedSnapshotFromJson('{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"}}'), + null, + ); + }); + + it("parses focused refs without falling back to caller refs", () => { + assert.deepEqual( + parseCmuxFocusedSnapshotFromJson( + '{"caller":{"surface_ref":"surface:1","pane_ref":"pane:1"},"focused":{"surface_ref":"surface:2","pane_ref":"pane:3"}}', + ), + { surfaceRef: "surface:2", paneRef: "pane:3" }, + ); + }); + }); + + describe("parseCmuxPaneRefForSurface", () => { + it("parses top-level pane refs for a surface", () => { + assert.equal( + parseCmuxPaneRefForSurface({ surface_ref: "surface:7", pane_ref: "pane:4" }, "surface:7"), + "pane:4", + ); + }); + + it("parses caller pane refs for identify --surface output", () => { + assert.equal( + parseCmuxPaneRefForSurface( + { caller: { surface_ref: "surface:7", pane_ref: "pane:4" } }, + "surface:7", + ), + "pane:4", + ); + }); + + it("returns null when the surface does not match", () => { + assert.equal( + parseCmuxPaneRefForSurface({ surface_ref: "surface:8", pane_ref: "pane:4" }, "surface:7"), + null, + ); + }); + }); + + describe("parseCmuxPaneRefForSurfaceFromJson", () => { + it("returns null for malformed JSON text", () => { + assert.equal(parseCmuxPaneRefForSurfaceFromJson("not json", "surface:7"), null); + }); + + it("parses caller refs from cmux identify --surface JSON text", () => { + assert.equal( + parseCmuxPaneRefForSurfaceFromJson( + '{"caller":{"surface_ref":"surface:7","pane_ref":"pane:4"}}', + "surface:7", + ), + "pane:4", + ); + }); + }); + + describe("zellij placement", () => { + const pane = (overrides: any) => ({ + id: 1, + is_plugin: false, + is_floating: false, + is_selectable: true, + exited: false, + pane_rows: 20, + pane_columns: 80, + tab_id: 1, + ...overrides, + }); + + it("matches Zellij direction and minimum split rules", () => { + assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 11 })), "right"); + assert.equal(predictZellijSplitDirection(pane({ pane_rows: 11, pane_columns: 5 })), "down"); + assert.equal(predictZellijSplitDirection(pane({ pane_rows: 5, pane_columns: 10 })), null); + assert.equal(predictZellijSplitDirection(pane({ pane_rows: 4, pane_columns: 80 })), null); + + assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 11 })), true); + assert.equal(canSplitZellijPane(pane({ pane_rows: 11, pane_columns: 5 })), true); + assert.equal(canSplitZellijPane(pane({ pane_rows: 5, pane_columns: 10 })), false); + assert.equal(canSplitZellijPane(pane({ pane_rows: 4, pane_columns: 80 })), false); + + assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 100 }), 80, 20), false); + assert.equal(canSplitZellijPane(pane({ pane_rows: 45, pane_columns: 100 }), 80, 20), true); + assert.equal(canSplitZellijPane(pane({ pane_rows: 30, pane_columns: 170 }), 80, 20), true); + assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 47 }), 50, 10), false); + assert.equal(canSplitZellijPane(pane({ pane_rows: 31, pane_columns: 77 }), 50, 10), true); + }); + + it("uses tab-scoped split only when all Zellij split candidates are safe", () => { + const plan = selectZellijPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 40, pane_columns: 120 }), + pane({ id: 11, tab_id: 1, pane_rows: 120, pane_columns: 100 }), + pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }), + ], + 10, + ); + + assert.deepEqual(plan, { + mode: "split", + anchorPaneId: 11, + targetPaneId: 11, + tabId: 1, + splitDirection: "down", + }); + }); + + it("stacks when any Zellij split candidate would fall below Pi's configured minimum", () => { + const plan = selectZellijPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 100, pane_columns: 47 }), + pane({ id: 11, tab_id: 1, pane_rows: 31, pane_columns: 77 }), + ], + 10, + 50, + 10, + ); + + assert.deepEqual(plan, { + mode: "stack", + anchorPaneId: 11, + targetPaneId: 11, + tabId: 1, + }); + }); + + it("stacks when Zellij would split a pane below Pi's usable minimum", () => { + const plan = selectZellijPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 20, pane_columns: 20 }), + pane({ id: 11, tab_id: 1, pane_rows: 18, pane_columns: 60 }), + pane({ id: 12, tab_id: 1, pane_rows: 10, pane_columns: 70 }), + ], + 10, + ); + + assert.deepEqual(plan, { + mode: "stack", + anchorPaneId: 11, + targetPaneId: 11, + tabId: 1, + }); + }); + + it("never chooses the parent pane as the stack target", () => { + const plan = selectZellijStackPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 }), + pane({ id: 11, tab_id: 1, pane_rows: 10, pane_columns: 20 }), + pane({ id: 12, tab_id: 1, pane_rows: 8, pane_columns: 30 }), + ], + 10, + ); + + assert.deepEqual(plan, { + mode: "stack", + anchorPaneId: 12, + targetPaneId: 12, + tabId: 1, + }); + }); + + it("does not stack when the only usable pane is the parent", () => { + const plan = selectZellijStackPlacement( + [pane({ id: 10, tab_id: 1, pane_rows: 60, pane_columns: 200 })], + 10, + ); + + assert.equal(plan, null); + }); + + it("stacks on the largest usable non-parent pane when none can split", () => { + const plan = selectZellijPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }), + pane({ id: 11, tab_id: 1, pane_rows: 6, pane_columns: 8 }), + pane({ id: 12, tab_id: 2, pane_rows: 60, pane_columns: 200 }), + ], + 10, + ); + + assert.deepEqual(plan, { + mode: "stack", + anchorPaneId: 11, + targetPaneId: 11, + tabId: 1, + }); + }); + + it("ignores floating, plugin, exited, unselectable, and other-tab panes", () => { + const plan = selectZellijPlacement( + [ + pane({ id: 10, tab_id: 1, pane_rows: 5, pane_columns: 10 }), + pane({ id: 11, tab_id: 1, pane_rows: 60, pane_columns: 200, is_floating: true }), + pane({ id: 12, tab_id: 1, pane_rows: 60, pane_columns: 200, is_plugin: true }), + pane({ id: 13, tab_id: 1, pane_rows: 60, pane_columns: 200, exited: true }), + pane({ id: 14, tab_id: 1, pane_rows: 60, pane_columns: 200, is_selectable: false }), + pane({ id: 15, tab_id: 2, pane_rows: 60, pane_columns: 200 }), + ], + 10, + ); + + assert.equal(plan, null); + }); + + it("returns null when the parent pane cannot be found", () => { + assert.equal(selectZellijPlacement([pane({ id: 10 })], 99), null); + }); + }); + + describe("isCmuxAvailable", () => { + it("returns boolean based on CMUX_SOCKET_PATH", () => { + // Can't easily mock env in node:test, just verify it returns a boolean + const result = isCmuxAvailable(); + assert.equal(typeof result, "boolean"); + }); + }); + + describe("isWezTermAvailable", () => { + it("returns boolean based on WEZTERM_UNIX_SOCKET", () => { + const result = isWezTermAvailable(); + assert.equal(typeof result, "boolean"); + }); + }); +});