From c47a6533365e588605510b8e39809a08b260b2f9 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 27 Jun 2026 00:19:44 +0300 Subject: [PATCH] ported over subagents, minor changes to functionality, refined ui --- .gitignore | 3 +- README.md | 134 +++----- agents/{ => .retired}/claude-code.md | 0 agents/{ => .retired}/planner.md | 0 agents/{ => .retired}/reviewer.md | 0 agents/.retired/scout.md | 105 +++++++ agents/{ => .retired}/visual-tester.md | 0 agents/.retired/worker.md | 104 +++++++ agents/researcher.md | 51 ++++ agents/scout.md | 117 ++----- agents/worker.md | 124 +++----- pi-extension/subagents/index.ts | 356 +++++++++++++++------- pi-extension/subagents/plan-skill.md | 203 ------------ pi-extension/subagents/session.ts | 74 +++++ pi-extension/subagents/subagent-done.ts | 10 +- pi-extension/subagents/tools/safe-bash.ts | 63 ++++ test/test.ts | 219 +++++++++++-- 17 files changed, 958 insertions(+), 605 deletions(-) rename agents/{ => .retired}/claude-code.md (100%) rename agents/{ => .retired}/planner.md (100%) rename agents/{ => .retired}/reviewer.md (100%) create mode 100644 agents/.retired/scout.md rename agents/{ => .retired}/visual-tester.md (100%) create mode 100644 agents/.retired/worker.md create mode 100644 agents/researcher.md delete mode 100644 pi-extension/subagents/plan-skill.md create mode 100644 pi-extension/subagents/tools/safe-bash.ts diff --git a/.gitignore b/.gitignore index 85e4326..a231c90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ config.json -.DS_Store \ No newline at end of file +.DS_Store +.memory/ \ No newline at end of file diff --git a/README.md b/README.md index 3b91535..6bbb619 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch ### Extensions -**Subagents** — 4 main-session tools + 3 commands, plus 1 subagent-only tool: +**Subagents** — 4 main-session tools + 1 command, plus 1 subagent-only tool: | Tool | Description | | -------------------- | ------------------------------------------------------------------------------------------- | @@ -73,19 +73,17 @@ Subagent panes are created without stealing keyboard focus (cmux, tmux). Launch | 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 | Model | Tools | Role | +| -------------- | ------ | ------------------------------------------------ | ----------------------------------------------------------------- | +| **scout** | Haiku | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon — maps files, patterns, conventions | +| **researcher** | Sonnet | `web_search`, `web_fetch` | Web research — searches and synthesizes a sourced brief | +| **worker** | Sonnet | `read`, `write`, `edit`, `safe_bash`, `web_search`, `web_fetch` + spawning | General implementer — writes code, runs commands; may dispatch `scout`/`researcher` to protect its context | + +The **worker** is the only bundled agent granted the spawning toolset, and it may only spawn `scout` and `researcher` (see [`subagent_agents`](#subagent_agents)). `scout` and `researcher` are autonomous (`auto-exit: true`); `worker` runs interactively. 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. @@ -125,7 +123,7 @@ The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot 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. +**Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `worker`, or any `fork: true` spawn) 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 @@ -154,10 +152,10 @@ cp config.json.example config.json 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..." }); +subagent({ name: "Fix", 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" }); +// Interactive worker the user can drive in its own pane +subagent({ name: "Worker", agent: "worker", task: "Implement the dark mode toggle" }); // Custom working directory subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer", task: "..." }); @@ -232,43 +230,6 @@ await caller_ping({ --- -## 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): @@ -279,9 +240,8 @@ name: my-agent description: Does something specific model: anthropic/claude-sonnet-4-6 thinking: minimal -tools: read, bash, edit, write +tools: read, edit, write, safe_bash, web_search session-mode: lineage-only -spawning: false --- # My Agent @@ -289,6 +249,8 @@ spawning: false You are a specialized agent that does X... ``` +The `tools` field is a strict allowlist. The child process is launched with extension discovery disabled (`--no-extensions`) and only the extensions backing the listed tools are loaded back in. An agent that lists no spawning tools and no `subagent_agents` simply cannot spawn anything. + ### Frontmatter Reference | Field | Type | Description | @@ -297,12 +259,11 @@ You are a specialized agent that does X... | `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` | +| `tools` | string | Strict allowlist of tool names. Built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) plus custom-extension tools (`web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`). Only the extensions backing these tools are loaded into the child. | +| `subagent_agents` | string | Comma-separated list of agent names this agent may spawn. **Presence of this field grants the full spawning toolset** (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`) and restricts spawn targets to the listed agents. Omit it and the agent cannot spawn at all. | | `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). | +| `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, researcher); not for interactive ones (worker). 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", ... })`. | @@ -321,11 +282,11 @@ Choose how a subagent session starts: `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. +`fork: true` on the tool call always forces the `fork` mode for that specific spawn. ```yaml --- -name: planner +name: worker session-mode: lineage-only --- ``` @@ -342,8 +303,8 @@ When set to `true`, the agent session shuts down automatically as soon as the ag **When to use:** -- ✅ Autonomous agents (scout, worker, reviewer) that run to completion -- ❌ Interactive agents (planner, iterate) where the user drives the session +- ✅ Autonomous agents (scout, researcher) that run to completion +- ❌ Interactive agents (worker) where the user drives the session ```yaml --- @@ -356,7 +317,7 @@ auto-exit: true 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. +**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. a `fork: true` spawn) 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. @@ -367,7 +328,7 @@ Controls whether status transitions (`stalled`, `recovered`) wake the parent ses ```yaml --- -name: planner +name: worker # interactive defaults to true because auto-exit is not set --- ``` @@ -382,39 +343,31 @@ 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: +Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools `. Only the tools named in the agent's `tools` frontmatter are exposed, and only the extensions that register those tools are loaded back in via explicit `--extension` flags. There is no global default toolset and no deny-list to maintain — an agent gets exactly what it asks for. -### `spawning: false` +### Granting the ability to spawn: `subagent_agents` -Denies all subagent lifecycle tools (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`): +Spawning is **off by default**. An agent cannot spawn sub-agents unless its frontmatter declares a `subagent_agents` list. Presence of that field: + +1. Grants the full spawning toolset (`subagent`, `subagent_interrupt`, `subagents_list`, `subagent_resume`) — you do **not** list these in `tools`. +2. Loads this extension into the child process. +3. Restricts the child to spawning **only** the named agents. The child's `subagents_list` is filtered to that set, and `subagent` calls for any other agent are rejected. Enforced via the `PI_SUBAGENT_ALLOWED` env var. ```yaml --- name: worker -spawning: false ---- -``` - -### `deny-tools` - -Fine-grained control over individual extension tools: - -```yaml ---- -name: focused-agent -deny-tools: subagent +tools: read, write, edit, safe_bash, web_search, web_fetch +subagent_agents: scout, researcher --- ``` ### 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 | +| Agent | `subagent_agents` | Rationale | +| ---------- | ------------------- | ------------------------------------------------------ | +| worker | `scout, researcher` | Delegates recon/research to protect its own context | +| scout | _(omitted)_ | Read-only recon; should gather context, not spawn | +| researcher | _(omitted)_ | Web research only; should research, not spawn | --- @@ -445,7 +398,7 @@ Set a default `cwd` in agent frontmatter: --- name: game-designer cwd: ./agents/game-designer -spawning: false +tools: read, write, edit, safe_bash --- ``` @@ -453,13 +406,12 @@ spawning: false ## Tools Widget -Every sub-agent session displays a compact tools widget showing available and denied tools. Toggle with `Ctrl+J`: +Every sub-agent session displays a compact tools widget showing the agent's allowlisted tools. Toggle with `Ctrl+Alt+O`: ``` -[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, ... +[scout] — 4 tools (Ctrl+Alt+O) ← collapsed +[scout] — 4 available (Ctrl+Alt+O to collapse) ← expanded + read, grep, find, ls ``` --- diff --git a/agents/claude-code.md b/agents/.retired/claude-code.md similarity index 100% rename from agents/claude-code.md rename to agents/.retired/claude-code.md diff --git a/agents/planner.md b/agents/.retired/planner.md similarity index 100% rename from agents/planner.md rename to agents/.retired/planner.md diff --git a/agents/reviewer.md b/agents/.retired/reviewer.md similarity index 100% rename from agents/reviewer.md rename to agents/.retired/reviewer.md diff --git a/agents/.retired/scout.md b/agents/.retired/scout.md new file mode 100644 index 0000000..da06174 --- /dev/null +++ b/agents/.retired/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/.retired/visual-tester.md similarity index 100% rename from agents/visual-tester.md rename to agents/.retired/visual-tester.md diff --git a/agents/.retired/worker.md b/agents/.retired/worker.md new file mode 100644 index 0000000..03b3559 --- /dev/null +++ b/agents/.retired/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/agents/researcher.md b/agents/researcher.md new file mode 100644 index 0000000..a2eea37 --- /dev/null +++ b/agents/researcher.md @@ -0,0 +1,51 @@ +--- +name: researcher +description: Web researcher — searches the web and synthesizes findings +tools: web_search, web_fetch +model: anthropic/claude-sonnet-4-6 +thinking: medium +system-prompt: append +auto-exit: true +--- + +You are a research specialist. Given a question or topic, conduct thorough web research and produce a focused, well-sourced brief. + +You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description. + +Process: +1. Break the question into 2-4 searchable facets +2. Search with `web_search` using varied angles +3. Read the answers. Identify what's well-covered, what has gaps. +4. For the 2-3 most promising source URLs, use `web_fetch` to get full page content +5. Synthesize everything into a brief that directly answers the question + +Search strategy — always vary your angles: +- Direct answer query (the obvious one) +- Authoritative source query (official docs, specs, primary sources) +- Practical experience query (case studies, benchmarks, real-world usage) +- Recent developments query (only if the topic is time-sensitive) + +Evaluation — what to keep vs drop: +- Official docs and primary sources outweigh blog posts and forum threads +- Recent sources outweigh stale ones +- Sources that directly address the question outweigh tangentially related ones +- Drop: SEO filler, outdated info, beginner tutorials (unless that's the audience) + +If the first round of searches doesn't fully answer the question, search again with refined queries targeting the gaps. + +Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: + +## Summary +2-3 sentence direct answer. + +## Findings +Numbered findings with inline source citations: +1. **Finding** — explanation. [Source](url) +2. **Finding** — explanation. [Source](url) + +## Sources +- Kept: Source Title (url) — why relevant +- Dropped: Source Title — why excluded + +## Gaps +What couldn't be answered. Suggested next steps. diff --git a/agents/scout.md b/agents/scout.md index da06174..b5bbec0 100644 --- a/agents/scout.md +++ b/agents/scout.md @@ -1,105 +1,40 @@ --- name: scout -description: Fast codebase reconnaissance - maps existing code, conventions, and patterns for a task -tools: read, bash -deny-tools: claude +description: Fast codebase recon — explores files, finds patterns, maps architecture +tools: read, grep, find, ls model: anthropic/claude-haiku-4-5 -output: context.md -spawning: false -auto-exit: true +thinking: medium system-prompt: append +auto-exit: true --- -# Scout Agent +You are a scout agent. Quickly investigate a codebase and return structured findings. -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 operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description. You are read-only: never build, test, or modify anything. -**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. +Thoroughness (infer from task, default medium): +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types ---- +Strategy: +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files -## Principles +Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: -- **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. +## Files Found +List with exact line ranges: +1. `path/to/file.ts` (lines 10-50) — Description +2. `path/to/other.ts` (lines 100-150) — Description ---- +## Key Code +Critical types, interfaces, or functions with actual code snippets. -## Approach +## Architecture +Brief explanation of how the pieces connect. -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 +## Start Here +Which file to look at first and why. diff --git a/agents/worker.md b/agents/worker.md index 03b3559..d8c4d17 100644 --- a/agents/worker.md +++ b/agents/worker.md @@ -1,104 +1,74 @@ --- name: worker -description: Implements tasks from todos - writes code, runs tests, commits with polished messages -tools: read, bash, write, edit -deny-tools: claude +description: General-purpose worker — reads, writes, and edits code +tools: read, write, edit, safe_bash, web_search, web_fetch +subagent_agents: scout, researcher model: anthropic/claude-sonnet-4-6 -thinking: minimal -spawning: false -auto-exit: true +thinking: medium system-prompt: append --- -# Worker Agent +You are a worker agent. You operate in an isolated context — you have no knowledge of any prior conversation. All necessary context will be provided in the task description. -You 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 run in your own interactive pane. Work autonomously to complete the assigned task, but the user can step in at any time. When you have finished the task, call the `subagent_done` tool to signal completion and hand control back. If you get stuck and need a decision from the orchestrator, call `caller_ping` with your question. -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. +Guidelines: +- Read files before editing to understand existing code +- Make targeted edits, not wholesale rewrites +- Use `safe_bash` for running commands (tests, builds, installs, etc.) +- If something fails, diagnose and fix it +- Your FINAL assistant message (before calling `subagent_done`) should summarize what you did and what changed ---- +## Delegation — protecting your context window -## Engineering Standards +Your context is finite. Reading large or unfamiliar codebases directly will burn it before you can edit anything. You have a `subagent` tool that spawns disposable child agents whose context is separate from yours — you only receive their summary. Use it. -### You Own What You Ship -Care about readability, naming, structure. If something feels off, fix it or flag it. +You can dispatch: +- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*. +- **researcher** — web research (web_search, web_fetch). Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references). -### 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. +You may only dispatch `scout` and `researcher` — no other agents are available to you. -### Read Before You Edit -Never modify code you haven't read. Understand existing patterns and conventions first. +### When to dispatch a scout vs. read directly -### Investigate, Don't Guess -When something breaks, read error messages, form a hypothesis based on evidence. No shotgun debugging. +Dispatch a scout when: +- The task brief names a feature/area but not specific files ("fix the auth flow", "add a field to user settings") +- You'd need to grep + read 5+ files just to orient +- You only need to know *where* something lives or *what shape* it has, not its full source -### Evidence Before Assertions -Never say "done" without proving it. Run the test, show the output. No "should work." +Read directly when: +- The brief gives you explicit file paths +- You already know the file you need to edit +- You need the exact bytes for an `edit` call (scouts return summaries, not verbatim source — re-read the 1–3 files you actually edit) ---- +A good rhythm: **scout to find, read to edit.** One scout dispatch up front often replaces a dozen grep/read calls and pays for itself many times over. -## Workflow +### When to dispatch a researcher vs. web_fetch directly -### 1. Read Your Task +Dispatch a researcher when: +- The question is open-ended ("what's the idiomatic way to X in library Y") +- You'd need to search + read 3+ pages to triangulate +- You want sources synthesized, not raw HTML in your context -Everything you need is in the task message: -- What to implement (usually a TODO reference) -- Plan path or context (if provided) -- Acceptance criteria +Fetch directly when: +- You already have the exact URL (a known docs page, a GitHub issue) +- You need a single specific piece of information from one page -If a plan path is mentioned, read it. If a TODO is referenced, read its details: -``` -todo(action: "get", id: "TODO-xxxx") -``` +### Parallelism -### 2. Verify Todo Has Examples & References +If you need two independent investigations (e.g. "map the auth code" AND "look up the library's session API"), emit multiple `subagent` tool calls in the same turn — they run in parallel automatically. Don't serialize independent work. After spawning, the results arrive as steer messages — don't poll or fabricate them. -**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) +### What a subagent doesn't replace -**If any of these are missing, STOP and report back.** Do NOT guess or improvise. Write a clear message explaining what's missing: +Subagents can't edit files for you. You still do the `edit`/`write` calls yourself, with the focused context the scouts gave you. Treat them as a context-protecting prefetch, not a substitute for thinking. -> "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." +## Output format when done -Then **release the todo** and exit. The orchestrator will provide the missing context and re-assign. +## Changes Made +- `path/to/file.ts` — what changed and why -This is not a failure — it's quality control. Guessing leads to building the wrong thing. Asking leads to building the right thing. +## Verification +How you verified the changes work (tests run, build succeeded, etc.) -### 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") -``` +## Notes +Any caveats, follow-up items, or decisions made. diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 8f2dba9..d1b98c6 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -24,8 +24,6 @@ import { getMuxBackend, sendEscape, shellEscape, - renameCurrentTab, - renameWorkspace, readScreen, } from "./cmux.ts"; @@ -33,6 +31,8 @@ import { findLastAssistantMessage, getNewEntries, seedSubagentSessionFile, + summarizeSessionStats, + type SessionStats, } from "./session.ts"; import { type StatusSnapshot, @@ -136,8 +136,13 @@ interface AgentDefaults { tools?: string; skills?: string; thinking?: string; - denyTools?: string; - spawning?: boolean; + /** + * If set (non-empty), this agent is granted the full subagent spawning + * toolset and may only spawn the listed agents. Presence of this field — + * not the `tools` list — is what grants spawning. Enforced in the child via + * the PI_SUBAGENT_ALLOWED env var. + */ + subagentAgents?: string[]; autoExit?: boolean; interactive?: boolean; systemPromptMode?: "append" | "replace"; @@ -160,46 +165,62 @@ interface ListedAgentDefinition extends AgentDefinition { source: AgentSource; } -/** Tools that are gated by `spawning: false` */ -const SPAWNING_TOOLS = new Set([ +/** + * The full subagent lifecycle/spawning toolset registered by this extension. + * An agent is granted these (and this extension is loaded into its child + * process) only when its frontmatter declares a non-empty `subagent_agents`. + */ +const SPAWNING_TOOLS = [ "subagent", "subagent_interrupt", "subagents_list", "subagent_resume", -]); +] as const; -/** - * 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; -} +/** Built-in tools pi provides natively — no extension needs to be loaded. */ +const BUILTIN_TOOLS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]); /** 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"); } +/** + * Map a custom (non-built-in) tool name to the pi-extension file that + * registers it. Used to build the child's `--extension` whitelist after + * `--no-extensions` disables global discovery. Returns undefined for built-in + * tools and for unknown names (which simply won't be granted). + */ +function getToolExtensionPath(tool: string): string | undefined { + if (BUILTIN_TOOLS.has(tool)) return undefined; + // The four spawning tools are registered by THIS extension. + if ((SPAWNING_TOOLS as readonly string[]).includes(tool)) { + return fileURLToPath(import.meta.url); + } + const extBase = join(getAgentConfigDir(), "extensions"); + const map: Record = { + web_search: join(extBase, "web-search", "index.ts"), + web_fetch: join(extBase, "web-fetch", "index.ts"), + video_extract: join(extBase, "video-extract", "index.ts"), + youtube_search: join(extBase, "youtube-search", "index.ts"), + google_image_search: join(extBase, "google-image-search", "index.ts"), + safe_bash: join(SUBAGENTS_DIR, "tools", "safe-bash.ts"), + }; + return map[tool]; +} + +/** + * When this process was spawned as a restricted subagent, the parent pins the + * set of agents it may itself spawn via PI_SUBAGENT_ALLOWED. `null` means no + * restriction (top-level session, or an unrestricted child). + */ +const SUBAGENT_ALLOWLIST: Set | null = (() => { + const raw = process.env.PI_SUBAGENT_ALLOWED; + if (!raw) return null; + const list = raw.split(",").map((s) => s.trim()).filter(Boolean); + return list.length > 0 ? new Set(list) : null; +})(); + function getBundledAgentsDir(): string { return join(SUBAGENTS_DIR, "../../agents"); } @@ -213,6 +234,13 @@ function parseOptionalBoolean(value: string | undefined): boolean | undefined { return value != null ? value === "true" : undefined; } +/** Parse a comma-separated frontmatter value into a trimmed list (or undefined). */ +function parseCommaList(value: string | undefined): string[] | undefined { + if (value == null) return undefined; + const list = value.split(",").map((s) => s.trim()).filter(Boolean); + return list.length > 0 ? list : undefined; +} + function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined { if (value === "standalone" || value === "lineage-only" || value === "fork") { return value; @@ -241,8 +269,7 @@ function parseAgentDefinition(content: string, fallbackName: string): AgentDefin : undefined, skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"), thinking: getFrontmatterValue(frontmatter, "thinking"), - denyTools: getFrontmatterValue(frontmatter, "deny-tools"), - spawning: parseOptionalBoolean(getFrontmatterValue(frontmatter, "spawning")), + subagentAgents: parseCommaList(getFrontmatterValue(frontmatter, "subagent_agents")), autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")), interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")), sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")), @@ -274,7 +301,10 @@ function discoverAgentDefinitions(): ListedAgentDefinition[] { } } - return [...agents.values()]; + // When this process is itself a restricted subagent, only expose the agents + // it is permitted to spawn (PI_SUBAGENT_ALLOWED). Top-level sessions see all. + const all = [...agents.values()]; + return SUBAGENT_ALLOWLIST ? all.filter((a) => SUBAGENT_ALLOWLIST.has(a.name)) : all; } function resolveSubagentPaths( @@ -380,6 +410,71 @@ function formatElapsed(seconds: number): string { return `${m}m ${s}s`; } +/** Compact token count: 850, 3.2k, 45k. */ +function formatTokens(n: number): string { + return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`; +} + +/** + * Known context-window sizes by model id substring, used for the context-usage + * gauge. Unknown models fall back to a window-less "Nk ctx" label. + */ +function contextWindowFor(model: string | null | undefined): number | undefined { + if (!model) return undefined; + const m = model.toLowerCase(); + if (m.includes("claude")) return 200_000; + if (m.includes("gpt-4.1") || m.includes("gpt-4o")) return 128_000; + if (m.includes("gemini")) return 1_000_000; + return undefined; +} + +/** Context-usage gauge: "18.0%/200k" when window known, else "37k ctx". */ +function formatContextUsage(tokens: number, contextWindow: number | undefined): string { + if (!contextWindow) return `${formatTokens(tokens)} ctx`; + const pct = (tokens / contextWindow) * 100; + const maxStr = + contextWindow >= 1_000_000 + ? `${(contextWindow / 1_000_000).toFixed(1)}M` + : `${Math.round(contextWindow / 1000)}k`; + return `${pct.toFixed(1)}%/${maxStr}`; +} + +/** + * Build the dim usage line for a completed subagent, mirroring the format of + * the in-process subagents extension: "↑in ↓out R… W… $cost · ctx". + * `theme.fg` is applied by the caller; this returns plain segments joined. + */ +function formatUsageSegments(stats: SessionStats): string[] { + const segs: string[] = []; + if (stats.inputTokens) segs.push(`↑${formatTokens(stats.inputTokens)}`); + if (stats.outputTokens) segs.push(`↓${formatTokens(stats.outputTokens)}`); + if (stats.cacheReadTokens) segs.push(`R${formatTokens(stats.cacheReadTokens)}`); + if (stats.cacheWriteTokens) segs.push(`W${formatTokens(stats.cacheWriteTokens)}`); + if (stats.cost) segs.push(`$${stats.cost.toFixed(3)}`); + return segs; +} + +/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */ +const ICON_GREEN = "\x1b[38;2;126;186;103m"; +const ICON_YELLOW = "\x1b[38;2;214;181;94m"; +const ICON_RED = "\x1b[38;2;224;108;117m"; +const ICON_DIM = "\x1b[38;2;128;128;128m"; + +/** Map a live status kind to a colored single-char icon for the widget. */ +function widgetIcon(kind: StatusSnapshot["kind"]): string { + switch (kind) { + case "active": + case "running": + return `${ICON_YELLOW}⟳${RST}`; + case "stalled": + return `${ICON_RED}⟳${RST}`; + case "waiting": + case "starting": + default: + return `${ICON_DIM}○${RST}`; + } +} + /** * Wait long enough for a freshly created pane to finish shell startup. * @@ -481,6 +576,8 @@ interface SubagentResult { /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */ errorMessage?: string; ping?: { name: string; message: string }; + /** Aggregate usage/model/tool stats parsed from the completed session file. */ + stats?: SessionStats; } /** @@ -606,8 +703,9 @@ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): st 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 icon = widgetIcon(snapshot.kind); + const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `; const right = statusConfig.enabled ? formatWidgetRightLabel(snapshot) : agent.cli === "claude" @@ -670,15 +768,25 @@ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const; * 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 { +function buildSubagentToolAllowlist( + effectiveTools?: string, + opts?: { grantSpawning?: boolean }, +): string | null { const requested = (effectiveTools ?? "") .split(",") .map((tool) => tool.trim()) .filter(Boolean); - if (requested.length === 0) return null; + const grantSpawning = opts?.grantSpawning ?? false; + + // No explicit tool restriction and no spawning grant → don't pass --tools at + // all (the child keeps its default toolset). + if (requested.length === 0 && !grantSpawning) return null; const allow = new Set(requested); + if (grantSpawning) { + for (const tool of SPAWNING_TOOLS) allow.add(tool); + } for (const tool of SUBAGENT_CONTROL_TOOLS) { allow.add(tool); } @@ -905,7 +1013,7 @@ export const __test__ = { buildPiPromptArgs, formatWidgetRightLabel, observeRunningSubagent, - resolveDenyTools, + getToolExtensionPath, resolveInterruptTarget, requestSubagentInterrupt, handleSubagentInterrupt, @@ -913,6 +1021,11 @@ export const __test__ = { resolveResumeLaunchBehavior, runningSubagents, formatElapsed, + formatTokens, + formatContextUsage, + contextWindowFor, + formatUsageSegments, + widgetIcon, }; function startWidgetRefresh() { @@ -998,7 +1111,9 @@ async function launchSubagent( 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); + // An agent with a non-empty subagent_agents list is granted the spawning + // toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED). + const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0); const identity = agentDefs?.body ?? params.systemPrompt ?? null; const systemPromptMode = agentDefs?.systemPromptMode; const identityInSystemPrompt = systemPromptMode && identity; @@ -1111,12 +1226,28 @@ async function launchSubagent( parts.push(flag, shellEscape(syspromptPath)); } - const toolAllowlist = buildSubagentToolAllowlist(effectiveTools); + // Default-deny model: when an agent restricts its tools (or is granted the + // spawning toolset), we disable global extension discovery and re-enable only + // the extensions backing the whitelisted tools. Bare/fork spawns with no tool + // restriction keep their full default toolset and all global extensions. + const toolAllowlist = buildSubagentToolAllowlist(effectiveTools, { grantSpawning }); if (toolAllowlist) { + parts.push("--no-extensions"); parts.push("--tools", shellEscape(toolAllowlist)); + + const extPaths = new Set(); + for (const tool of toolAllowlist.split(",")) { + const extPath = getToolExtensionPath(tool); + // Skip extensions that can't be resolved on disk so a missing optional + // extension never breaks the launch (the tool simply won't be available). + if (extPath && existsSync(extPath)) extPaths.add(extPath); + } + for (const extPath of extPaths) { + parts.push("-e", shellEscape(extPath)); + } } - // Build env prefix: denied tools + subagent identity + config dir propagation + // Build env prefix: subagent identity + config dir propagation + spawn allowlist const envParts: string[] = []; // If the target cwd has its own .pi/agent/, use that as the config root. @@ -1127,8 +1258,8 @@ async function launchSubagent( 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(","))}`); + if (grantSpawning && agentDefs?.subagentAgents) { + envParts.push(`PI_SUBAGENT_ALLOWED=${shellEscape(agentDefs.subagentAgents.join(","))}`); } envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`); if (params.agent) { @@ -1316,6 +1447,8 @@ async function watchSubagent( : "Sub-agent exited without output"; } + const stats = existsSync(sessionFile) ? summarizeSessionStats(sessionFile) : null; + closeSurface(surface); runningSubagents.delete(running.id); @@ -1328,6 +1461,7 @@ async function watchSubagent( elapsed, ping: result.ping, ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), + ...(stats ? { stats } : {}), }; } catch (err: any) { try { @@ -1383,19 +1517,13 @@ export default function subagentsExtension(pi: ExtensionAPI) { 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); + // The spawning tools are always registered here. Whether a child process can + // actually see/use them is governed by the parent's `--tools` allowlist and + // by which extensions are loaded into the child (default-deny --no-extensions + // + explicit -e). See launchSubagent(). // ── subagent tool ── - if (shouldRegister("subagent")) - pi.registerTool({ + pi.registerTool({ name: "subagent", label: "Subagent", description: @@ -1429,6 +1557,22 @@ export default function subagentsExtension(pi: ExtensionAPI) { }; } + // Enforce the spawn allowlist when this process is itself a restricted + // subagent (e.g. a worker may only spawn scout/researcher). + if (params.agent && SUBAGENT_ALLOWLIST && !SUBAGENT_ALLOWLIST.has(params.agent)) { + return { + content: [ + { + type: "text", + text: + `You may not spawn the "${params.agent}" agent. ` + + `You are only permitted to spawn: ${[...SUBAGENT_ALLOWLIST].join(", ")}.`, + }, + ], + details: { error: "agent not in allowlist" }, + }; + } + // Validate prerequisites if (!isMuxAvailable()) { return muxUnavailableResult(); @@ -1499,6 +1643,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { sessionFile: result.sessionFile, ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), ...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}), + ...(result.stats ? { stats: result.stats } : {}), }, }, { triggerTurn: true, deliverAs: "steer" }, @@ -1552,7 +1697,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { ? theme.fg("dim", ` in ${partialArgs.cwd}`) : ""; let text = - "▸ " + + "○ " + theme.fg("toolTitle", theme.bold(name)) + agent + cwdHint; @@ -1582,7 +1727,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { // "Started" result — tool returned immediately if (details?.status === "started") { return new Text( - theme.fg("accent", "▸") + + theme.fg("accent", "⟳") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — started"), @@ -1598,8 +1743,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { }); // ── subagent_interrupt tool ── - if (shouldRegister("subagent_interrupt")) - pi.registerTool({ + pi.registerTool({ name: "subagent_interrupt", label: "Interrupt Subagent", description: @@ -1622,7 +1766,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { renderCall(args, theme) { const target = args.id ? `${args.id}` : args.name ?? "(unknown)"; return new Text( - theme.fg("accent", "▸") + + theme.fg("accent", "○") + " " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — interrupt turn"), @@ -1635,7 +1779,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { const details = result.details as any; if (details?.status === "interrupt_requested") { return new Text( - theme.fg("accent", "▸") + + theme.fg("accent", "✗") + " " + theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) + theme.fg("dim", " — interrupt requested"), @@ -1650,8 +1794,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { }); // ── subagents_list tool ── - if (shouldRegister("subagents_list")) - pi.registerTool({ + pi.registerTool({ name: "subagents_list", label: "List Subagents", description: @@ -1706,8 +1849,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { // ── subagent_resume tool ── - if (shouldRegister("subagent_resume")) - pi.registerTool({ + pi.registerTool({ name: "subagent_resume", label: "Resume Subagent", description: @@ -1745,7 +1887,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { 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); @@ -1757,7 +1899,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { if (details?.status === "started") { return new Text( - theme.fg("accent", "▸") + + theme.fg("accent", "⟳") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — resumed"), @@ -1963,18 +2105,6 @@ export default function subagentsExtension(pi: ExtensionAPI) { }, }); - // /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 ", @@ -2020,17 +2150,40 @@ export default function subagentsExtension(pi: ExtensionAPI) { const bgFn = failed ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); + const stats = (details.stats ?? null) as SessionStats | null; 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 modelTag = stats?.model ? theme.fg("dim", ` (${stats.model})`) : ""; + const titleSegment = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag}${modelTag} ${theme.fg("dim", "—")} `; + + // Success: icon already conveys "completed", so show "N tools · duration" + // like the in-process extension. Failure: surface the failure reason. + let header: string; + if (failed) { + const reason = errorMessage ? "failed (provider/agent error)" : `failed (exit ${exitCode})`; + header = `${titleSegment}${theme.fg("error", reason)} ${theme.fg("dim", `· ${elapsed}`)}`; + } else { + const toolPart = stats ? `${stats.toolCount} tools · ${elapsed}` : elapsed; + header = `${titleSegment}${theme.fg("dim", toolPart)}`; + } + + // Usage line: ↑in ↓out R… W… $cost · context-gauge (color-coded by %). + let usageLine: string | null = null; + if (stats) { + const segs = formatUsageSegments(stats).map((s) => theme.fg("dim", s)); + if (stats.contextTokens > 0) { + const window = contextWindowFor(stats.model); + const ctxStr = formatContextUsage(stats.contextTokens, window); + const pct = window ? (stats.contextTokens / window) * 100 : 0; + const coloredCtx = + pct > 90 ? theme.fg("error", ctxStr) : pct > 70 ? theme.fg("warning", ctxStr) : theme.fg("dim", ctxStr); + segs.push(coloredCtx); + } + if (segs.length > 0) usageLine = segs.join(theme.fg("dim", " ")); + } - 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) @@ -2047,6 +2200,7 @@ export default function subagentsExtension(pi: ExtensionAPI) { // Build content for the box const contentLines = [header]; + if (usageLine) contentLines.push(usageLine); if (options.expanded) { // Full view: complete summary + session info @@ -2148,35 +2302,5 @@ export default function subagentsExtension(pi: ExtensionAPI) { }; }); - // /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 deleted file mode 100644 index 91c4594..0000000 --- a/pi-extension/subagents/plan-skill.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -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/session.ts b/pi-extension/subagents/session.ts index 2bcb1f7..573ed6a 100644 --- a/pi-extension/subagents/session.ts +++ b/pi-extension/subagents/session.ts @@ -178,3 +178,77 @@ export function mergeNewEntries( } return entries; } + +export interface SessionStats { + model: string | null; + toolCount: number; + /** Cumulative token usage across all assistant turns. */ + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + /** Current context size: the last assistant turn's totalTokens. */ + contextTokens: number; + /** Cumulative cost in USD across all assistant turns. */ + cost: number; +} + +/** + * Parse a completed subagent session JSONL into aggregate stats for display: + * model, tool-call count, cumulative token usage + cost, and current context + * size. Cumulative usage fields are summed across every assistant turn; the + * context size is taken from the last assistant turn's `totalTokens` (the live + * context window occupancy). Returns null if the file can't be read. + */ +export function summarizeSessionStats(sessionFile: string): SessionStats | null { + let entries: SessionEntry[]; + try { + entries = readEntries(sessionFile); + } catch { + return null; + } + + const stats: SessionStats = { + model: null, + toolCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + contextTokens: 0, + cost: 0, + }; + + for (const entry of entries) { + if (entry.type === "model_change") { + const modelId = (entry as { modelId?: unknown }).modelId; + if (typeof modelId === "string" && modelId) stats.model = modelId; + continue; + } + if (entry.type !== "message") continue; + const msg = (entry as MessageEntry).message; + if (msg.role !== "assistant") continue; + + const model = (msg as { model?: unknown }).model; + if (typeof model === "string" && model) stats.model = model; + + for (const block of msg.content) { + if (block.type === "toolCall") stats.toolCount++; + } + + const usage = (msg as { usage?: Record }).usage; + if (usage && typeof usage === "object") { + const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + stats.inputTokens += num(usage.input); + stats.outputTokens += num(usage.output); + stats.cacheReadTokens += num(usage.cacheRead); + stats.cacheWriteTokens += num(usage.cacheWrite); + const total = num(usage.totalTokens); + if (total > 0) stats.contextTokens = total; + const cost = usage.cost; + if (cost && typeof cost === "object") stats.cost += num((cost as Record).total); + } + } + + return stats; +} diff --git a/pi-extension/subagents/subagent-done.ts b/pi-extension/subagents/subagent-done.ts index 73aaf32..1dea3d7 100644 --- a/pi-extension/subagents/subagent-done.ts +++ b/pi-extension/subagents/subagent-done.ts @@ -1,6 +1,6 @@ /** * Extension loaded into sub-agents. - * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J) + * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O) * - Provides a `subagent_done` tool for autonomous agents to self-terminate */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -102,7 +102,7 @@ export default function (pi: ExtensionAPI) { 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 hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)"); const toolList = toolNames .map((name: string) => theme.fg("dim", name)) @@ -129,7 +129,7 @@ export default function (pi: ExtensionAPI) { denied.length > 0 ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`) : ""; - const hint = theme.fg("muted", " (Ctrl+J to expand)"); + const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)"); const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0); box.addChild(content); @@ -256,8 +256,8 @@ export default function (pi: ExtensionAPI) { recorder.sessionShutdown((event as any).reason); }); - // Toggle expand/collapse with Ctrl+J - pi.registerShortcut("ctrl+j", { + // Toggle expand/collapse with Ctrl+Alt+O + pi.registerShortcut("ctrl+alt+o", { description: "Toggle subagent tools widget", handler: (ctx) => { expanded = !expanded; diff --git a/pi-extension/subagents/tools/safe-bash.ts b/pi-extension/subagents/tools/safe-bash.ts new file mode 100644 index 0000000..d38a647 --- /dev/null +++ b/pi-extension/subagents/tools/safe-bash.ts @@ -0,0 +1,63 @@ +/** + * Safe bash extension for the worker subagent. + * Wraps the built-in bash tool with dangerous command blocking. + * + * Loaded into a child pi process via `--extension` when an agent's `tools` + * frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts. + */ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { createBashTool } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +const DANGEROUS_PATTERNS = [ + /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/, + /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/, + /\bsudo\b/, + /\bmkfs\b/, + /\bdd\s+if=/, + /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, + />\s*\/dev\/[sh]d[a-z]/, + /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//, + /\bchown\s+(-[a-zA-Z]+\s+)?root/, + /\bcurl\s.*\|\s*(ba)?sh/, + /\bwget\s.*\|\s*(ba)?sh/, + /\bshutdown\b/, + /\breboot\b/, + /\binit\s+0\b/, + /\bkill\s+-9\s+1\b/, + /\bkillall\b/, +]; + +function isDangerous(command: string): string | null { + const normalized = command.replace(/\\\n/g, " "); + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(normalized)) { + return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`; + } + } + return null; +} + +export default function (pi: ExtensionAPI) { + const bashTool = createBashTool(process.cwd()); + + pi.registerTool({ + name: "safe_bash", + label: "Safe Bash", + description: + "Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).", + parameters: Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional( + Type.Number({ description: "Timeout in seconds (optional)" }), + ), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + const danger = isDangerous(params.command); + if (danger) { + throw new Error(danger); + } + return bashTool.execute(toolCallId, params, signal, onUpdate); + }, + }); +} diff --git a/test/test.ts b/test/test.ts index 5503bb8..60e1ffb 100644 --- a/test/test.ts +++ b/test/test.ts @@ -15,6 +15,7 @@ import { copySessionFile, mergeNewEntries, seedSubagentSessionFile, + summarizeSessionStats, } from "../pi-extension/subagents/session.ts"; import { @@ -475,6 +476,75 @@ describe("session.ts", () => { assert.equal(targetLines.length, 3); }); }); + + describe("summarizeSessionStats", () => { + const asstWithUsage = (id: string, opts: { + model?: string; + tools?: string[]; + usage?: Record; + }) => ({ + type: "message", + id, + parentId: "user-001", + message: { + role: "assistant", + ...(opts.model ? { model: opts.model } : {}), + content: [ + { type: "text", text: "ok" }, + ...(opts.tools ?? []).map((name, i) => ({ type: "toolCall", name, id: `${id}-tc${i}` })), + ], + ...(opts.usage ? { usage: opts.usage } : {}), + }, + }); + + it("aggregates tokens/cost cumulatively and tracks last context size", () => { + const file = createSessionFile(dir, [ + SESSION_HEADER, + { type: "model_change", id: "mc-001", parentId: null, modelId: "claude-sonnet-4-6" }, + USER_MSG, + asstWithUsage("a1", { + tools: ["read", "grep"], + usage: { input: 100, output: 50, cacheRead: 1000, cacheWrite: 200, totalTokens: 1350, cost: { total: 0.01 } }, + }), + asstWithUsage("a2", { + tools: ["write"], + usage: { input: 30, output: 70, cacheRead: 2000, cacheWrite: 0, totalTokens: 3500, cost: { total: 0.02 } }, + }), + ]); + const stats = summarizeSessionStats(file)!; + assert.equal(stats.model, "claude-sonnet-4-6"); + assert.equal(stats.toolCount, 3); + assert.equal(stats.inputTokens, 130); + assert.equal(stats.outputTokens, 120); + assert.equal(stats.cacheReadTokens, 3000); + assert.equal(stats.cacheWriteTokens, 200); + // contextTokens is the LAST assistant turn's totalTokens, not the sum. + assert.equal(stats.contextTokens, 3500); + assert.ok(Math.abs(stats.cost - 0.03) < 1e-9); + }); + + it("prefers per-message model over model_change", () => { + const file = createSessionFile(dir, [ + SESSION_HEADER, + { type: "model_change", id: "mc-001", parentId: null, modelId: "claude-haiku-4-5" }, + asstWithUsage("a1", { model: "claude-sonnet-4-6", usage: { totalTokens: 10, cost: { total: 0 } } }), + ]); + assert.equal(summarizeSessionStats(file)!.model, "claude-sonnet-4-6"); + }); + + it("handles missing usage gracefully", () => { + const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG]); + const stats = summarizeSessionStats(file)!; + assert.equal(stats.toolCount, 0); + assert.equal(stats.inputTokens, 0); + assert.equal(stats.cost, 0); + assert.equal(stats.contextTokens, 0); + }); + + it("returns null for an unreadable file", () => { + assert.equal(summarizeSessionStats(join(dir, "does-not-exist.jsonl")), null); + }); + }); }); describe("status.ts", () => { @@ -987,26 +1057,57 @@ describe("subagent discovery", () => { ); }); - it("bundled scout/worker/reviewer agents resolve as non-interactive; planner resolves as interactive", () => { - for (const name of ["scout", "worker", "reviewer"]) { + it("bundled scout/researcher resolve as non-interactive; worker resolves as interactive", () => { + for (const name of ["scout", "researcher"]) { 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)`, + `${name} should resolve as non-interactive (autonomous, auto-exit)`, ); } - const planner = testApi.loadAgentDefaults("planner"); - assert.ok(planner, "expected bundled planner to be discoverable"); + const worker = testApi.loadAgentDefaults("worker"); + assert.ok(worker, "expected bundled worker to be discoverable"); assert.equal( - testApi.resolveEffectiveInteractive({ name: "planner", task: "" }, planner), + testApi.resolveEffectiveInteractive({ name: "worker", task: "" }, worker), true, - "planner should resolve as interactive (no auto-exit)", + "worker should resolve as interactive (no auto-exit)", ); }); + it("worker is granted the spawning toolset restricted to scout and researcher", () => { + const worker = testApi.loadAgentDefaults("worker"); + assert.ok(worker, "expected bundled worker to be discoverable"); + assert.deepEqual(worker.subagentAgents, ["scout", "researcher"]); + + const allowlist = testApi.buildSubagentToolAllowlist(worker.tools, { grantSpawning: true }); + assert.ok(allowlist, "expected an allowlist"); + const tools = new Set(allowlist!.split(",")); + for (const t of ["subagent", "subagent_interrupt", "subagents_list", "subagent_resume"]) { + assert.ok(tools.has(t), `expected spawning tool ${t} in worker allowlist`); + } + assert.ok(tools.has("safe_bash"), "expected worker to keep safe_bash"); + }); + + it("scout and researcher are not granted spawning tools", () => { + for (const name of ["scout", "researcher"]) { + const defs = testApi.loadAgentDefaults(name); + assert.ok(defs, `expected bundled agent ${name} to be discoverable`); + assert.equal(defs.subagentAgents, undefined, `${name} should not declare subagent_agents`); + } + }); + + it("getToolExtensionPath maps custom tools and skips built-ins", () => { + assert.equal(testApi.getToolExtensionPath("read"), undefined); + assert.equal(testApi.getToolExtensionPath("bash"), undefined); + assert.ok(testApi.getToolExtensionPath("web_search")?.endsWith("web-search/index.ts")); + assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts")); + // Spawning tools are registered by this extension itself. + assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts")); + }); + it("ignores invalid session-mode values", async () => { await withIsolatedAgentEnv(async ({ projectAgentsDir }) => { writeAgentFile( @@ -1343,19 +1444,28 @@ describe("cmux.ts interpretExitSidecar", () => { }); }); describe("commands", () => { - it("/iterate always emits a full-context fork tool call", () => { + it("/subagent emits a spawn tool call for a known agent", () => { 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"); + const subagent = registeredCommands.find((command) => command.name === "subagent"); + assert.ok(subagent, "expected /subagent to be registered"); - iterate.handler("Fix the bug", {}); + subagent.handler("scout map the auth code", { + ui: { notify() {} }, + }); assert.equal(sentUserMessages.length, 1); - assert.match(sentUserMessages[0], /fork: true/); - assert.match(sentUserMessages[0], /name: "Iterate"/); + assert.match(sentUserMessages[0], /agent: "scout"/); + assert.match(sentUserMessages[0], /map the auth code/); + }); + + it("does not register the removed /iterate or /plan commands", () => { + const { api, registeredCommands } = createMockExtensionApi(); + (subagentsModule as any).default(api); + assert.equal(registeredCommands.find((c) => c.name === "iterate"), undefined); + assert.equal(registeredCommands.find((c) => c.name === "plan"), undefined); }); }); @@ -1373,14 +1483,6 @@ describe("tool registration", () => { }); }); - 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(); @@ -2085,6 +2187,81 @@ describe("subagents widget rendering", () => { }); }); +describe("subagent display helpers", () => { + const testApi = (subagentsModule as any).__test__; + + describe("formatTokens", () => { + it("renders raw counts below 1k, 1 decimal below 10k, rounded k above", () => { + assert.equal(testApi.formatTokens(850), "850"); + assert.equal(testApi.formatTokens(3200), "3.2k"); + assert.equal(testApi.formatTokens(45000), "45k"); + }); + }); + + describe("contextWindowFor", () => { + it("maps known model families and returns undefined otherwise", () => { + assert.equal(testApi.contextWindowFor("claude-sonnet-4-6"), 200_000); + assert.equal(testApi.contextWindowFor("gemini-2.5-pro"), 1_000_000); + assert.equal(testApi.contextWindowFor("some-unknown-model"), undefined); + assert.equal(testApi.contextWindowFor(null), undefined); + }); + }); + + describe("formatContextUsage", () => { + it("shows a percent gauge when the window is known", () => { + assert.equal(testApi.formatContextUsage(36_000, 200_000), "18.0%/200k"); + assert.equal(testApi.formatContextUsage(500_000, 1_000_000), "50.0%/1.0M"); + }); + + it("falls back to a window-less ctx label when unknown", () => { + assert.equal(testApi.formatContextUsage(37_000, undefined), "37k ctx"); + }); + }); + + describe("formatUsageSegments", () => { + it("emits arrow/cache/cost segments, skipping zero fields", () => { + const segs = testApi.formatUsageSegments({ + model: "claude-sonnet-4-6", + toolCount: 3, + inputTokens: 3200, + outputTokens: 890, + cacheReadTokens: 45000, + cacheWriteTokens: 0, + contextTokens: 7000, + cost: 0.042, + }); + assert.deepEqual(segs, ["↑3.2k", "↓890", "R45k", "$0.042"]); + }); + + it("returns an empty list when there is no usage", () => { + assert.deepEqual( + testApi.formatUsageSegments({ + model: null, + toolCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + contextTokens: 0, + cost: 0, + }), + [], + ); + }); + }); + + describe("widgetIcon", () => { + it("maps active/running to a glyph and waiting/starting to another", () => { + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); + assert.equal(strip(testApi.widgetIcon("active")), "⟳"); + assert.equal(strip(testApi.widgetIcon("running")), "⟳"); + assert.equal(strip(testApi.widgetIcon("stalled")), "⟳"); + assert.equal(strip(testApi.widgetIcon("waiting")), "○"); + assert.equal(strip(testApi.widgetIcon("starting")), "○"); + }); + }); +}); + describe("cmux.ts", () => { describe("shellEscape", () => { it("wraps in single quotes", () => {