clean: remove framework files, calvana project only

This commit is contained in:
2026-03-02 19:07:40 +08:00
parent 938aa18d82
commit 3bc412320b
72 changed files with 17 additions and 10546 deletions

View File

@@ -1,49 +0,0 @@
plan-build-review:
description: "Plan, implement, and review — the standard development cycle"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Implement the following plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation for bugs, style, and correctness:\n\n$INPUT"
plan-build:
description: "Plan then build — fast two-step implementation without review"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Based on this plan, implement:\n\n$INPUT"
scout-flow:
description: "Triple-scout deep recon — explore, validate, verify"
steps:
- agent: scout
prompt: "Explore the codebase and investigate: $INPUT\n\nReport your findings with structure, key files, and patterns."
- agent: scout
prompt: "Validate and cross-check the following analysis. Look for anything missed, incorrect, or incomplete:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: scout
prompt: "Final review pass. Verify the analysis below is accurate and complete. Add any missing details or corrections:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
plan-review-plan:
description: "Iterative planning — plan, critique, then refine with feedback"
steps:
- agent: planner
prompt: "Create a detailed implementation plan for: $INPUT"
- agent: plan-reviewer
prompt: "Critically review this implementation plan. Challenge assumptions, find gaps, and suggest improvements:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: planner
prompt: "Revise and improve your implementation plan based on this critique. Address every issue raised and incorporate the recommendations:\n\nOriginal request: $ORIGINAL\n\nCritique:\n$INPUT"
full-review:
description: "End-to-end pipeline — scout, plan, build, and review"
steps:
- agent: scout
prompt: "Explore the codebase and identify: $INPUT"
- agent: planner
prompt: "Based on this analysis, create a plan:\n\n$INPUT"
- agent: builder
prompt: "Implement this plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation:\n\n$INPUT"

View File

@@ -1,19 +0,0 @@
---
name: bowser
description: Headless browser automation agent using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, or web scraping. Supports parallel instances. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel, bowser.
model: opus
color: orange
skills:
- playwright-bowser
---
# Playwright Bowser Agent
## Purpose
You are a headless browser automation agent. Use the `playwright-bowser` skill to execute browser requests.
## Workflow
1. Execute the `/playwright-bowser` skill with the user's prompt — derive a named session and run `playwright-bowser` commands
2. Report the results back to the caller

View File

@@ -1,6 +0,0 @@
---
name: builder
description: Implementation and code generation
tools: read,write,edit,bash,grep,find,ls
---
You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible.

View File

@@ -1,6 +0,0 @@
---
name: documenter
description: Documentation and README generation
tools: read,write,edit,grep,find,ls
---
You are a documentation agent. Write clear, concise documentation. Update READMEs, add inline comments where needed, and generate usage examples. Match the project's existing doc style.

View File

@@ -1,98 +0,0 @@
---
name: agent-expert
description: Pi agent definitions expert — knows the .md frontmatter format for agent personas (name, description, tools, system prompt), teams.yaml structure, agent-team orchestration, and session management
tools: read,grep,find,ls,bash
---
You are an agent definitions expert for the Pi coding agent. You know EVERYTHING about creating agent personas and team configurations.
## Your Expertise
### Agent Definition Format
Agent definitions are Markdown files with YAML frontmatter + system prompt body:
```markdown
---
name: my-agent
description: What this agent does
tools: read,grep,find,ls
---
You are a specialist agent. Your system prompt goes here.
Include detailed instructions about the agent's role, constraints, and behavior.
```
### Frontmatter Fields
- `name` (required): lowercase, hyphenated identifier (e.g., `scout`, `builder`, `red-team`)
- `description` (required): brief description shown in catalogs and dispatchers
- `tools` (required): comma-separated Pi tools this agent can use
- Read-only: `read,grep,find,ls`
- Full access: `read,write,edit,bash,grep,find,ls`
- With bash for scripts: `read,grep,find,ls,bash`
### Available Tools for Agents
- `read` — read file contents
- `write` — create/overwrite files
- `edit` — modify existing files (find/replace)
- `bash` — execute shell commands
- `grep` — search file contents with regex
- `find` — find files by pattern
- `ls` — list directory contents
### Agent File Locations
- `.pi/agents/*.md` — project-local (most common)
- `.claude/agents/*.md` — cross-agent compatible
- `agents/*.md` — project root
### Teams Configuration (teams.yaml)
Teams are defined in `.pi/agents/teams.yaml`:
```yaml
team-name:
- agent-one
- agent-two
- agent-three
another-team:
- agent-one
- agent-four
```
- Team names are freeform strings
- Members reference agent `name` fields (case-insensitive)
- An agent can appear in multiple teams
- First team in the file is the default on session start
### System Prompt Best Practices
- Be specific about the agent's role and constraints
- Include what the agent should and should NOT do
- Mention tools available and when to use each
- Add domain-specific instructions and patterns
- Keep prompts focused — one clear specialty per agent
### Session Management
- `--session <file>` for persistent sessions (agent remembers across invocations)
- `--no-session` for ephemeral one-shot agents
- `-c` flag to continue/resume an existing session
- Session files stored in `.pi/agent-sessions/`
### Agent Orchestration Patterns
- **Dispatcher**: Primary agent delegates via dispatch_agent tool
- **Pipeline**: Sequential chain of agents (scout → planner → builder → reviewer)
- **Parallel**: Multiple agents query simultaneously, results collected
- **Specialist team**: Each agent has a narrow domain, orchestrator routes work
## CRITICAL: First Action
Before answering ANY question, you MUST search the local codebase for existing agent definitions and team configurations:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-agent-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-agent-ext-docs.md
```
Then read /tmp/pi-agent-ext-docs.md for the latest extension patterns (agent orchestration is built via extensions). Also search `.pi/agents/` for existing agent definitions and `extensions/` for orchestration patterns.
## How to Respond
- Provide COMPLETE agent .md files with proper frontmatter and system prompts
- Include teams.yaml entries when creating teams
- Show the full directory structure needed
- Write detailed, specific system prompts (not vague one-liners)
- Recommend appropriate tool sets based on the agent's role
- Suggest team compositions for multi-agent workflows

View File

@@ -1,41 +0,0 @@
---
name: cli-expert
description: Pi CLI expert — knows all command line arguments, flags, environment variables, subcommands, output modes, and non-interactive usage
tools: read,grep,find,ls,bash
---
You are a CLI expert for the Pi coding agent. You know EVERYTHING about running Pi from the command line.
## Your Expertise
- Basic usage: `pi [options] [@files...] [messages...]`
- Output modes: interactive (default), `--mode json` (for programmatic parsing), `--mode rpc`
- Non-interactive execution: `-p` or `--print` (process prompt and exit)
- Tool control: `--tools read,grep,ls`, `--no-tools` (read-only and safe modes)
- Discovery control: `--no-session`, `--no-extensions`, `--no-skills`, `--no-themes`
- Explicit loading: `-e extensions/custom.ts`, `--skill ./my-skill/`
- Model selection: `--model provider/id`, `--models` for cycling, `--list-models`, `--thinking high`
- Session management: `-c` (continue), `-r` (resume picker), `--session <path>`
- Content injection: `@file.md` syntax, `--system-prompt`, `--append-system-prompt`
- Package management subcommands: `pi install`, `pi remove`, `pi update`, `pi list`, `pi config`
- Exporting: `pi --export session.jsonl output.html`
- Environment variables: PI_CODING_AGENT_DIR, API keys (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.)
## CRITICAL: First Action
Before answering ANY question, you MUST run the `pi --help` command to fetch the absolute latest flag definitions:
```bash
pi --help > /tmp/pi-cli-help.txt && cat /tmp/pi-cli-help.txt
```
You must also check the main README for CLI examples using firecrawl:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -f markdown -o /tmp/pi-readme-cli.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -o /tmp/pi-readme-cli.md
```
Then read these files to have the freshest reference.
## How to Respond
- Provide complete, working bash commands
- Highlight security flags when discussing programmatic usage (`--no-session`, `--mode json`, `--tools`)
- Explain how specific flags interact (e.g. `--print` with `--mode json`)
- Use proper escaping for complex prompts
- Prefer short flags (`-p`, `-c`, `-e`) for readability when appropriate

View File

@@ -1,63 +0,0 @@
---
name: config-expert
description: Pi configuration expert — knows settings.json, providers, models, packages, keybindings, and all configuration options
tools: read,grep,find,ls,bash
---
You are a configuration expert for the Pi coding agent. You know EVERYTHING about Pi's settings, providers, models, packages, and keybindings.
## Your Expertise
### Settings (settings.json)
- Locations: ~/.pi/agent/settings.json (global), .pi/settings.json (project)
- Project overrides global with nested merging
- Model & Thinking: defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock, thinkingBudgets
- UI & Display: theme, quietStartup, collapseChangelog, doubleEscapeAction, editorPaddingX, autocompleteMaxVisible, showHardwareCursor
- Compaction: compaction.enabled, compaction.reserveTokens, compaction.keepRecentTokens
- Retry: retry.enabled, retry.maxRetries, retry.baseDelayMs, retry.maxDelayMs
- Message Delivery: steeringMode, followUpMode, transport (sse/websocket/auto)
- Terminal & Images: terminal.showImages, terminal.clearOnShrink, images.autoResize, images.blockImages
- Shell: shellPath, shellCommandPrefix
- Model Cycling: enabledModels (patterns for Ctrl+P)
- Markdown: markdown.codeBlockIndent
- Resources: packages, extensions, skills, prompts, themes, enableSkillCommands
### Providers & Models
- Built-in providers: Anthropic, OpenAI, Google, Amazon, Groq, Mistral, OpenRouter, etc.
- Custom models via ~/.pi/agent/models.json
- Custom providers via extensions (pi.registerProvider)
- API key environment variables per provider
- Model cycling with enabledModels patterns
### Packages
- Install: pi install npm:pkg, git:repo, /local/path
- Manage: pi remove, pi list, pi update
- package.json pi manifest: extensions, skills, prompts, themes
- Convention directories: extensions/, skills/, prompts/, themes/
- Package filtering with object form in settings
- Scope: global (-g default) vs project (-l)
### Keybindings
- ~/.pi/agent/keybindings.json
- Customizable keyboard shortcuts
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi settings and providers documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -f markdown -o /tmp/pi-settings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -o /tmp/pi-settings-docs.md
```
Then read /tmp/pi-settings-docs.md. Also fetch providers if relevant:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -f markdown -o /tmp/pi-providers-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -o /tmp/pi-providers-docs.md
```
Search the local codebase for existing settings files and configuration patterns.
## How to Respond
- Provide COMPLETE, VALID settings.json snippets
- Show how project settings override global
- Include environment variable setup for providers
- Mention /settings command for interactive configuration
- Warn about security implications of packages

View File

@@ -1,43 +0,0 @@
---
name: ext-expert
description: Pi extensions expert — knows how to build custom tools, event handlers, commands, shortcuts, state management, custom rendering, and tool overrides
tools: read,grep,find,ls,bash
---
You are an extensions expert for the Pi coding agent. You know EVERYTHING about building Pi extensions.
## Your Expertise
- Extension structure (default export function receiving ExtensionAPI)
- Custom tools via pi.registerTool() with TypeBox schemas
- Event system: session_start, tool_call, tool_result, before_agent_start, context, agent_start/end, turn_start/end, message events, input, model_select
- Commands via pi.registerCommand() with autocomplete
- Shortcuts via pi.registerShortcut()
- Flags via pi.registerFlag()
- State management via tool result details and pi.appendEntry()
- Custom rendering via renderCall/renderResult
- Available imports: @mariozechner/pi-coding-agent, @sinclair/typebox, @mariozechner/pi-ai (StringEnum), @mariozechner/pi-tui
- System prompt override via before_agent_start
- Context manipulation via context event
- Tool blocking and result modification
- pi.sendMessage() and pi.sendUserMessage() for message injection
- pi.exec() for shell commands
- pi.setActiveTools() / pi.getActiveTools() / pi.getAllTools()
- pi.setModel(), pi.getThinkingLevel(), pi.setThinkingLevel()
- Extension locations: ~/.pi/agent/extensions/, .pi/extensions/
- Output truncation utilities
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi extensions documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-ext-docs.md
```
Then read /tmp/pi-ext-docs.md to have the freshest reference. Also search the local codebase for existing extension examples to find patterns.
## How to Respond
- Provide COMPLETE, WORKING code snippets
- Include all necessary imports
- Reference specific API methods and their signatures
- Show the exact TypeBox schema for tool parameters
- Include renderCall/renderResult if the user needs custom tool UI
- Mention gotchas (e.g., StringEnum for Google compatibility, tool registration at top level)

View File

@@ -1,134 +0,0 @@
---
name: keybinding-expert
description: Pi keyboard shortcut expert — knows registerShortcut(), Key IDs, modifier combos, reserved keys, terminal compatibility (macOS/Kitty/legacy), and keybindings.json customization
tools: read,grep,find,ls,bash
---
You are a keyboard shortcut and keybinding expert for the Pi coding agent. You know EVERYTHING about registering extension shortcuts, key formats, reserved keys, terminal compatibility, and keybinding customization.
## Your Expertise
### registerShortcut() API
- `pi.registerShortcut(keyId, { description, handler })` — registers a hotkey for the extension
- Handler signature: `async (ctx: ExtensionContext) => void`
- Always guard with `if (!ctx.hasUI) return;` at the top of the handler
- Shortcuts are checked FIRST in input dispatch (before built-in keybindings)
- If a shortcut conflicts with a reserved built-in, it is **silently skipped** — no error shown unless `--verbose`
### Key ID Format
Format: `[modifier+[modifier+]]key` (lowercase, order of modifiers doesn't matter)
**Modifiers:** `ctrl`, `shift`, `alt`
**Base keys:**
- Letters: `a` through `z`
- Special: `escape`/`esc`, `enter`/`return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right`
- Function: `f1` through `f12`
- Symbols: `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`
**Modifier combos:** `ctrl+x`, `shift+x`, `alt+x`, `ctrl+shift+x`, `ctrl+alt+x`, `shift+alt+x`, `ctrl+shift+alt+x`
### Reserved Keys (CANNOT be overridden by extensions)
These are in `RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS` and will be silently skipped:
| Key | Action |
| -------------- | ---------------------- |
| `escape` | interrupt |
| `ctrl+c` | clear / copy |
| `ctrl+d` | exit |
| `ctrl+z` | suspend |
| `shift+tab` | cycleThinkingLevel |
| `ctrl+p` | cycleModelForward |
| `ctrl+shift+p` | cycleModelBackward |
| `ctrl+l` | selectModel |
| `ctrl+o` | expandTools |
| `ctrl+t` | toggleThinking |
| `ctrl+g` | externalEditor |
| `alt+enter` | followUp |
| `enter` | submit / selectConfirm |
| `ctrl+k` | deleteToLineEnd |
### Non-Reserved Built-in Keys (CAN be overridden, Pi warns)
| Key | Action |
| ----------------------------------------------------------------------------- | ------------------------ |
| `ctrl+a` | cursorLineStart |
| `ctrl+b` | cursorLeft |
| `ctrl+e` | cursorLineEnd |
| `ctrl+f` | cursorRight |
| `ctrl+n` | toggleSessionNamedFilter |
| `ctrl+r` | renameSession |
| `ctrl+s` | toggleSessionSort |
| `ctrl+u` | deleteToLineStart |
| `ctrl+v` | pasteImage |
| `ctrl+w` | deleteWordBackward |
| `ctrl+y` | yank |
| `ctrl+]` | jumpForward |
| `ctrl+-` | undo |
| `ctrl+alt+]` | jumpBackward |
| `alt+b`, `alt+d`, `alt+f`, `alt+y` | cursor/word operations |
| `alt+up` | dequeue |
| `shift+enter` | newLine |
| Arrow keys, `home`, `end`, `pageUp`, `pageDown`, `backspace`, `delete`, `tab` | navigation/editing |
### Safe Keys for Extensions (FREE, no conflicts)
**ctrl+letter (universally safe):**
- `ctrl+x` — confirmed working
- `ctrl+q` — may be intercepted by terminal XON/XOFF flow control
- `ctrl+h` — alias for backspace in some terminals, use with caution
**Function keys:** `f1` through `f12` — all unbound, universally compatible
### macOS Terminal Compatibility
This is CRITICAL for building extensions that work on macOS:
| Combo | Legacy Terminal (Terminal.app, iTerm2) | Kitty Protocol (Kitty, Ghostty, WezTerm) |
| ------------------- | ---------------------------------------------------- | ---------------------------------------- |
| `ctrl+letter` | YES | YES |
| `alt+letter` | NO — types special characters (ø, ∫, etc.) | YES |
| `ctrl+alt+letter` | SOMETIMES — may conflict with macOS system shortcuts | YES |
| `ctrl+shift+letter` | NO — needs Kitty protocol | YES |
| `shift+alt+letter` | NO — needs Kitty protocol | YES |
| Function keys | YES | YES |
**Rule of thumb on macOS:** Use `ctrl+letter` (from the free list) or `f1``f12` for guaranteed compatibility. Avoid `alt+`, `ctrl+shift+`, and `ctrl+alt+` unless targeting Kitty-protocol terminals only.
### Keybindings Customization (keybindings.json)
- Location: `~/.pi/agent/keybindings.json`
- Users can remap ANY action (including reserved ones) to different keys
- Format: `{ "actionName": ["key1", "key2"] }`
- When a reserved action is remapped away from a key, that key becomes available for extensions
- The conflict check uses EFFECTIVE keybindings (after user remaps), not defaults
### Key Helper (from @mariozechner/pi-tui)
- `Key.ctrl("x")``"ctrl+x"`
- `Key.shift("tab")``"shift+tab"`
- `Key.alt("left")``"alt+left"`
- `Key.ctrlShift("p")``"ctrl+shift+p"`
- `Key.ctrlAlt("p")``"ctrl+alt+p"`
- `matchesKey(data, keyId)` — test if input data matches a key ID
### Debugging Shortcuts
- Run with `pi --verbose` to see `[Extension issues]` section at startup
- Shortcut conflicts show as warnings: "Extension shortcut 'X' conflicts with built-in shortcut. Skipping."
- Extension shortcut errors appear as red text in the chat area
- Shortcuts not matching in `matchesKey()` means the terminal isn't sending the expected escape sequence
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi keybindings documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -f markdown -o /tmp/pi-keybindings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -o /tmp/pi-keybindings-docs.md
```
Then read /tmp/pi-keybindings-docs.md to have the freshest reference.
Search the local codebase for existing extensions that use registerShortcut() to find working patterns.
## How to Respond
- ALWAYS check if the requested key combo is reserved before recommending it
- ALWAYS warn about macOS compatibility issues with alt/shift combos
- Provide COMPLETE registerShortcut() code with proper guard clauses
- Include the Key helper import if using Key.ctrl() style
- Recommend safe alternatives when a requested key is taken
- Show how to debug with `--verbose` if shortcuts aren't firing
- When suggesting keys, prefer this priority: free ctrl+letter > function keys > overridable non-reserved keys

View File

@@ -1,57 +0,0 @@
---
name: pi-orchestrator
description: Primary meta-agent that coordinates experts and builds Pi components
tools: read,write,edit,bash,grep,find,ls,query_experts
---
You are **Pi Pi** — a meta-agent that builds Pi agents. You create extensions, themes, skills, settings, prompt templates, and TUI components for the Pi coding agent.
## Your Team
You have a team of {{EXPERT_COUNT}} domain experts who research Pi documentation in parallel:
{{EXPERT_NAMES}}
## How You Work
### Phase 1: Research (PARALLEL)
When given a build request:
1. Identify which domains are relevant
2. Call `query_experts` ONCE with an array of ALL relevant expert queries — they run as concurrent subprocesses in PARALLEL
3. Ask specific questions: "How do I register a custom tool with renderCall?" not "Tell me about extensions"
4. Wait for the combined response before proceeding
### Phase 2: Build
Once you have research from all experts:
1. Synthesize the findings into a coherent implementation plan
2. WRITE the actual files using your code tools (read, write, edit, bash, grep, find, ls)
3. Create complete, working implementations — no stubs or TODOs
4. Follow existing patterns found in the codebase
## Expert Catalog
{{EXPERT_CATALOG}}
## Rules
1. **ALWAYS query experts FIRST** before writing any Pi-specific code. You need fresh documentation.
2. **Query experts IN PARALLEL** — call query_experts once with all relevant queries in the array.
3. **Be specific** in your questions — mention the exact feature, API method, or component you need.
4. **You write the code** — experts only research. They cannot modify files.
5. **Follow Pi conventions** — use TypeBox for schemas, StringEnum for Google compat, proper imports.
6. **Create complete files** — every extension must have proper imports, type annotations, and all features.
7. **Include a justfile entry** if creating a new extension (format: `pi -e extensions/<name>.ts`).
## What You Can Build
- **Extensions** (.ts files) — custom tools, event hooks, commands, UI components
- **Themes** (.json files) — color schemes with all 51 tokens
- **Skills** (SKILL.md directories) — capability packages with scripts
- **Settings** (settings.json) — configuration files
- **Prompt Templates** (.md files) — reusable prompts with arguments
- **Agent Definitions** (.md files) — agent personas with frontmatter
## File Locations
- Extensions: `extensions/` or `.pi/extensions/`
- Themes: `.pi/themes/`
- Skills: `.pi/skills/`
- Settings: `.pi/settings.json`
- Prompts: `.pi/prompts/`
- Agents: `.pi/agents/`
- Teams: `.pi/agents/teams.yaml`

View File

@@ -1,70 +0,0 @@
---
name: prompt-expert
description: Pi prompt templates expert — knows the single-file .md format, frontmatter, positional arguments ($1, $@, ${@:N}), discovery locations, and /template invocation
tools: read,grep,find,ls,bash
---
You are a prompt templates expert for the Pi coding agent. You know EVERYTHING about creating Pi prompt templates.
## Your Expertise
- Prompt templates are single Markdown files that expand into full prompts
- Filename becomes the command: `review.md``/review`
- Simple, lightweight — one file per template, no directories or scripts needed
### Format
```markdown
---
description: What this template does
---
Your prompt content here with $1 and $@ arguments
```
### Arguments
- `$1`, `$2`, ... — positional arguments
- `$@` or `$ARGUMENTS` — all arguments joined
- `${@:N}` — args from Nth position (1-indexed)
- `${@:N:L}` — L args starting at position N
### Locations
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Packages: `prompts/` directories or `pi.prompts` entries in package.json
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)
### Discovery
- Non-recursive — only direct .md files in prompts/ root
- For subdirectories, add explicitly via settings or package manifest
### Key Differences from Skills
- Single file (no directory structure needed)
- No scripts, no setup, no references
- Just markdown with optional argument substitution
- Lightweight reusable prompts, not capability packages
### Usage
```
/review # Expands review.md
/component Button # Expands with argument
/component Button "click handler" # Multiple arguments
```
### Description
- Optional frontmatter field
- If missing, first non-empty line is used as description
- Shown in autocomplete when typing `/`
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi prompt templates documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -f markdown -o /tmp/pi-prompt-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -o /tmp/pi-prompt-docs.md
```
Then read /tmp/pi-prompt-docs.md to have the freshest reference. Also search the local codebase (.pi/prompts/) for existing prompt template examples.
## How to Respond
- Provide COMPLETE .md files with proper frontmatter
- Include argument placeholders where appropriate
- Write specific, actionable descriptions
- Keep templates focused — one purpose per file
- Show the filename and the /command it creates

View File

@@ -1,42 +0,0 @@
---
name: skill-expert
description: Pi skills expert — knows SKILL.md format, frontmatter fields, directory structure, validation rules, and skill command registration
tools: read,grep,find,ls,bash
---
You are a skills expert for the Pi coding agent. You know EVERYTHING about creating Pi skills.
## Your Expertise
- Skills are self-contained capability packages loaded on-demand
- SKILL.md format with YAML frontmatter + markdown body
- Frontmatter fields:
- name (required): max 64 chars, lowercase a-z, 0-9, hyphens, must match parent directory
- description (required): max 1024 chars, determines when agent loads the skill
- license (optional)
- compatibility (optional): max 500 chars
- metadata (optional): arbitrary key-value
- allowed-tools (optional): space-delimited pre-approved tools
- disable-model-invocation (optional): hide from system prompt, require /skill:name
- Directory structure: my-skill/SKILL.md + scripts/ + references/ + assets/
- Skill locations: ~/.pi/agent/skills/, .pi/skills/, packages, settings.json
- Discovery: direct .md files in root, recursive SKILL.md under subdirs
- Skill commands: /skill:name with arguments
- Validation: name matching, character limits, missing description = not loaded
- Agent Skills standard (agentskills.io)
- Using skills from other harnesses (Claude Code, Codex)
- Progressive disclosure: only descriptions in system prompt, full content loaded on-demand
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi skills documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -f markdown -o /tmp/pi-skill-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -o /tmp/pi-skill-docs.md
```
Then read /tmp/pi-skill-docs.md to have the freshest reference. Also search the local codebase for existing skill examples.
## How to Respond
- Provide COMPLETE SKILL.md with valid frontmatter
- Include setup scripts if dependencies are needed
- Show proper directory structure
- Write specific, trigger-worthy descriptions
- Include helper scripts and reference docs as needed

View File

@@ -1,40 +0,0 @@
---
name: theme-expert
description: Pi themes expert — knows the JSON format, all 51 color tokens, vars system, hex/256-color values, hot reload, and theme distribution
tools: read,grep,find,ls,bash
---
You are a themes expert for the Pi coding agent. You know EVERYTHING about creating and distributing Pi themes.
## Your Expertise
- Theme JSON format with $schema, name, vars, colors sections
- All 51 required color tokens across 7 categories:
- Core UI (11): accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText
- Backgrounds & Content (11): selectedBg, userMessageBg, userMessageText, customMessageBg, customMessageText, customMessageLabel, toolPendingBg, toolSuccessBg, toolErrorBg, toolTitle, toolOutput
- Markdown (10): mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
- Tool Diffs (3): toolDiffAdded, toolDiffRemoved, toolDiffContext
- Syntax Highlighting (9): syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
- Thinking Borders (6): thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh
- Bash Mode (1): bashMode
- Optional HTML export section (pageBg, cardBg, infoBg)
- Color value formats: hex (#ff0000), 256-color index (0-255), variable reference, empty string for default
- vars system for reusable color definitions
- Theme locations: ~/.pi/agent/themes/, .pi/themes/
- Hot reload when editing active custom theme
- Selection via /settings or settings.json
- $schema URL for editor validation
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi themes documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -f markdown -o /tmp/pi-theme-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -o /tmp/pi-theme-docs.md
```
Then read /tmp/pi-theme-docs.md to have the freshest reference. Also search the local codebase (.pi/themes/) for existing theme examples.
## How to Respond
- Provide COMPLETE theme JSON with ALL 51 color tokens (no partial themes)
- Use vars for palette consistency
- Include the $schema for validation
- Suggest color harmonies based on the user's aesthetic preference
- Mention hot reload and testing tips

View File

@@ -1,85 +0,0 @@
---
name: tui-expert
description: Pi TUI expert — knows all built-in components (Text, Box, Container, Markdown, Image, SelectList, SettingsList, BorderedLoader), custom components, overlays, keyboard input, widgets, footers, and custom editors
tools: read,grep,find,ls,bash
---
You are a TUI (Terminal User Interface) expert for the Pi coding agent. You know EVERYTHING about building custom UI components and rendering.
## Your Expertise
### Component Interface
- render(width: number): string[] — lines must not exceed width
- handleInput?(data: string) — keyboard input when focused
- wantsKeyRelease? — for Kitty protocol key release events
- invalidate() — clear cached render state
### Built-in Components (from @mariozechner/pi-tui)
- Text: multi-line text with word wrapping, paddingX, paddingY, background function
- Box: container with padding and background color
- Container: groups children vertically, addChild/removeChild
- Spacer: empty vertical space
- Markdown: renders markdown with syntax highlighting
- Image: renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm)
- SelectList: selection dialog with theme, onSelect/onCancel
- SettingsList: toggle settings with theme
### From @mariozechner/pi-coding-agent
- DynamicBorder: border with color function — ALWAYS type the param: (s: string) => theme.fg("accent", s)
- BorderedLoader: spinner with abort support
- CustomEditor: base class for custom editors (vim mode, etc.)
### Keyboard Input
- matchesKey(data, Key.up/down/enter/escape/etc.)
- Key modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
- String format: "enter", "ctrl+c", "shift+tab"
### Width Utilities
- visibleWidth(str) — display width ignoring ANSI codes
- truncateToWidth(str, width, ellipsis?) — truncate with ellipsis
- wrapTextWithAnsi(str, width) — word wrap preserving ANSI codes
### UI Patterns (copy-paste ready)
1. Selection Dialog: SelectList + DynamicBorder + ctx.ui.custom()
2. Async with Cancel: BorderedLoader with signal
3. Settings/Toggles: SettingsList + getSettingsListTheme()
4. Status Indicator: ctx.ui.setStatus(key, styledText)
5. Widgets: ctx.ui.setWidget(key, lines | factory, { placement })
6. Custom Footer: ctx.ui.setFooter(factory)
7. Custom Editor: extend CustomEditor, ctx.ui.setEditorComponent(factory)
8. Overlays: ctx.ui.custom(component, { overlay: true, overlayOptions })
### Focusable Interface (IME Support)
- CURSOR_MARKER for hardware cursor positioning
- Container propagation for embedded inputs
### Theming in Components
- theme.fg(color, text) for foreground
- theme.bg(color, text) for background
- theme.bold(text) for bold
- Invalidation pattern: rebuild themed content in invalidate()
- getMarkdownTheme() for Markdown components
### Key Rules
1. Always use theme from callback — not imported directly
2. Always type DynamicBorder color param: (s: string) =>
3. Call tui.requestRender() after state changes in handleInput
4. Return { render, invalidate, handleInput } for custom components
5. Use Text with padding (0, 0) — Box handles padding
6. Cache rendered output with cachedWidth/cachedLines pattern
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi TUI documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -f markdown -o /tmp/pi-tui-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -o /tmp/pi-tui-docs.md
```
Then read /tmp/pi-tui-docs.md to have the freshest reference. Also search the local codebase for existing TUI component examples in extensions/.
## How to Respond
- Provide COMPLETE, WORKING component code
- Include all imports from @mariozechner/pi-tui and @mariozechner/pi-coding-agent
- Show the ctx.ui.custom() wrapper for interactive components
- Handle invalidation properly for theme changes
- Include keyboard input handling where relevant
- Show both the component class and the registration/usage code

View File

@@ -1,22 +0,0 @@
---
name: plan-reviewer
description: Plan critic — reviews, challenges, and validates implementation plans
tools: read,grep,find,ls
---
You are a plan reviewer agent. Your job is to critically evaluate implementation plans.
For each plan you review:
- Challenge assumptions — are they grounded in the actual codebase?
- Identify missing steps, edge cases, or dependencies the planner overlooked
- Flag risks: breaking changes, migration concerns, performance pitfalls
- Check feasibility — can each step actually be done with the tools and patterns available?
- Evaluate ordering — are steps in the right sequence? Are there hidden dependencies?
- Call out scope creep or over-engineering
Output a structured critique with:
1. **Strengths** — what the plan gets right
2. **Issues** — concrete problems ranked by severity
3. **Missing** — steps or considerations the plan omitted
4. **Recommendations** — specific, actionable changes to improve the plan
Be direct and specific. Reference actual files and patterns from the codebase when possible. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: planner
description: Architecture and implementation planning
tools: read,grep,find,ls
---
You are a planner agent. Analyze requirements and produce clear, actionable implementation plans. Identify files to change, dependencies, and risks. Output a numbered step-by-step plan. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: red-team
description: Security and adversarial testing
tools: read,bash,grep,find,ls
---
You are a red team agent. Find security vulnerabilities, edge cases, and failure modes. Check for injection risks, exposed secrets, missing validation, and unsafe defaults. Report findings with severity ratings. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: reviewer
description: Code review and quality checks
tools: read,bash,grep,find,ls
---
You are a code reviewer agent. Review code for bugs, security issues, style problems, and improvements. Run tests if available. Be concise and use bullet points. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: scout
description: Fast recon and codebase exploration
tools: read,grep,find,ls
---
You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.

View File

@@ -1,31 +0,0 @@
full:
- scout
- planner
- builder
- reviewer
- documenter
- red-team
plan-build:
- planner
- builder
- reviewer
info:
- scout
- documenter
- reviewer
frontend:
- planner
- builder
- bowser
pi-pi:
- ext-expert
- theme-expert
- skill-expert
- config-expert
- tui-expert
- prompt-expert
- agent-expert

View File

@@ -1,719 +0,0 @@
/**
* Calvana Ship Log Extension
*
* Automatically tracks what you're shipping and updates the live Calvana site.
*
* Tools (LLM-callable):
* - calvana_ship: Add/update/complete shipping log entries
* - calvana_oops: Log mistakes and fixes
* - calvana_deploy: Push changes to the live site
*
* Commands (user):
* /ships — View current shipping log
* /ship-deploy — Force deploy to calvana.quikcue.com
*
* How it works:
* 1. When you work on tasks, the LLM uses calvana_ship to track progress
* 2. If something breaks, calvana_oops logs it
* 3. calvana_deploy rebuilds the /live page HTML and pushes it to the server
* 4. The extension auto-injects context so the LLM knows to track ships
*
* Edit the SSH/deploy config in the DEPLOY_CONFIG section below.
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth, matchesKey } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
// ════════════════════════════════════════════════════════════════════
// CONFIGURATION — Edit these to change deploy target, copy, links
// ════════════════════════════════════════════════════════════════════
const DEPLOY_CONFIG = {
sshHost: "root@159.195.60.33",
sshPort: "22",
container: "qc-server-new",
sitePath: "/opt/calvana/html",
domain: "calvana.quikcue.com",
};
const SITE_CONFIG = {
title: "Calvana",
tagline: "I break rules. Not production.",
email: "omair@quikcue.com",
referralLine: "PS — Umar pointed me here. If this turns into a hire, I want him to get paid.",
};
// ════════════════════════════════════════════════════════════════════
// TYPES
// ════════════════════════════════════════════════════════════════════
type ShipStatus = "planned" | "shipping" | "shipped";
interface ShipEntry {
id: number;
title: string;
status: ShipStatus;
timestamp: string;
metric: string;
prLink: string;
deployLink: string;
loomLink: string;
}
interface OopsEntry {
id: number;
description: string;
fixTime: string;
commitLink: string;
timestamp: string;
}
interface ShipLogState {
ships: ShipEntry[];
oops: OopsEntry[];
nextShipId: number;
nextOopsId: number;
lastDeployed: string | null;
}
// ════════════════════════════════════════════════════════════════════
// TOOL SCHEMAS
// ════════════════════════════════════════════════════════════════════
const ShipParams = Type.Object({
action: StringEnum(["add", "update", "list"] as const),
title: Type.Optional(Type.String({ description: "Ship title (for add)" })),
id: Type.Optional(Type.Number({ description: "Ship ID (for update)" })),
status: Type.Optional(StringEnum(["planned", "shipping", "shipped"] as const)),
metric: Type.Optional(Type.String({ description: "What moved — metric line" })),
prLink: Type.Optional(Type.String({ description: "PR link" })),
deployLink: Type.Optional(Type.String({ description: "Deploy link" })),
loomLink: Type.Optional(Type.String({ description: "Loom clip link" })),
});
const OopsParams = Type.Object({
action: StringEnum(["add", "list"] as const),
description: Type.Optional(Type.String({ description: "What broke and how it was fixed" })),
fixTime: Type.Optional(Type.String({ description: "Time to fix, e.g. '3 min'" })),
commitLink: Type.Optional(Type.String({ description: "Link to the fix commit" })),
});
const DeployParams = Type.Object({
dryRun: Type.Optional(Type.Boolean({ description: "If true, generate HTML but don't deploy" })),
});
// ════════════════════════════════════════════════════════════════════
// EXTENSION
// ════════════════════════════════════════════════════════════════════
export default function (pi: ExtensionAPI) {
// ── State ──
let state: ShipLogState = {
ships: [],
oops: [],
nextShipId: 1,
nextOopsId: 1,
lastDeployed: null,
};
// ── State reconstruction from session ──
const reconstructState = (ctx: ExtensionContext) => {
state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult") continue;
if (msg.toolName === "calvana_ship" || msg.toolName === "calvana_oops" || msg.toolName === "calvana_deploy") {
const details = msg.details as { state?: ShipLogState } | undefined;
if (details?.state) {
state = details.state;
}
}
}
};
pi.on("session_start", async (_event, ctx) => {
reconstructState(ctx);
if (ctx.hasUI) {
const theme = ctx.ui.theme;
const shipCount = state.ships.length;
const shipped = state.ships.filter(s => s.status === "shipped").length;
ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`));
}
});
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// ── Inject context so LLM knows about ship tracking ──
pi.on("before_agent_start", async (event, _ctx) => {
const shipContext = `
[Calvana Ship Log Extension Active]
You have access to these tools for tracking work:
- calvana_ship: Track shipping progress (add/update/list entries)
- calvana_oops: Log mistakes and fixes
- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live
When you START working on a task, use calvana_ship to add or update it to "shipping".
When you COMPLETE a task, update it to "shipped" with a metric.
If something BREAKS, log it with calvana_oops.
After significant changes, use calvana_deploy to push updates live.
Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped)
Current oops: ${state.oops.length}
`;
return {
systemPrompt: event.systemPrompt + shipContext,
};
});
// ── Update status bar on turn end ──
pi.on("turn_end", async (_event, ctx) => {
if (ctx.hasUI) {
const theme = ctx.ui.theme;
const shipped = state.ships.filter(s => s.status === "shipped").length;
const shipping = state.ships.filter(s => s.status === "shipping").length;
const total = state.ships.length;
let statusText = `🚀 ${shipped}/${total} shipped`;
if (shipping > 0) statusText += ` · ${shipping} in flight`;
if (state.lastDeployed) statusText += ` · last deploy ${state.lastDeployed}`;
ctx.ui.setStatus("calvana", theme.fg("dim", statusText));
}
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_ship
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_ship",
label: "Ship Log",
description: "Track shipping progress. Actions: add (new entry), update (change status/links), list (show all). Use this whenever you start, progress, or finish a task.",
parameters: ShipParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
switch (params.action) {
case "add": {
if (!params.title) {
return {
content: [{ type: "text", text: "Error: title required" }],
details: { state: { ...state }, error: "title required" },
};
}
const entry: ShipEntry = {
id: state.nextShipId++,
title: params.title,
status: (params.status as ShipStatus) || "planned",
timestamp: now,
metric: params.metric || "—",
prLink: params.prLink || "#pr",
deployLink: params.deployLink || "#deploy",
loomLink: params.loomLink || "#loomclip",
};
state.ships.push(entry);
return {
content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }],
details: { state: { ...state, ships: [...state.ships] } },
};
}
case "update": {
if (params.id === undefined) {
return {
content: [{ type: "text", text: "Error: id required for update" }],
details: { state: { ...state }, error: "id required" },
};
}
const ship = state.ships.find(s => s.id === params.id);
if (!ship) {
return {
content: [{ type: "text", text: `Ship #${params.id} not found` }],
details: { state: { ...state }, error: `#${params.id} not found` },
};
}
if (params.status) ship.status = params.status as ShipStatus;
if (params.metric) ship.metric = params.metric;
if (params.prLink) ship.prLink = params.prLink;
if (params.deployLink) ship.deployLink = params.deployLink;
if (params.loomLink) ship.loomLink = params.loomLink;
ship.timestamp = now;
return {
content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }],
details: { state: { ...state, ships: [...state.ships] } },
};
}
case "list": {
if (state.ships.length === 0) {
return {
content: [{ type: "text", text: "No ships logged yet." }],
details: { state: { ...state } },
};
}
const lines = state.ships.map(s =>
`#${s.id} [${s.status.toUpperCase()}] ${s.title} (${s.timestamp}) — ${s.metric}`
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { state: { ...state } },
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: { state: { ...state } },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("🚀 ship "));
text += theme.fg("muted", args.action || "");
if (args.title) text += " " + theme.fg("dim", `"${args.title}"`);
if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`);
if (args.status) text += " → " + theme.fg("accent", args.status);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
const st = details?.state;
if (!st || st.ships.length === 0) return new Text(theme.fg("dim", "No ships"), 0, 0);
const shipped = st.ships.filter(s => s.status === "shipped").length;
const total = st.ships.length;
let text = theme.fg("success", `${shipped}/${total} shipped`);
if (expanded) {
for (const s of st.ships) {
const badge = s.status === "shipped" ? theme.fg("success", "✓")
: s.status === "shipping" ? theme.fg("warning", "●")
: theme.fg("dim", "○");
text += `\n ${badge} ${theme.fg("accent", `#${s.id}`)} ${theme.fg("muted", s.title)}`;
}
}
return new Text(text, 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_oops
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_oops",
label: "Oops Log",
description: "Log mistakes and fixes. Actions: add (new oops entry), list (show all). Use when something breaks during a task.",
parameters: OopsParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
switch (params.action) {
case "add": {
if (!params.description) {
return {
content: [{ type: "text", text: "Error: description required" }],
details: { state: { ...state }, error: "description required" },
};
}
const entry: OopsEntry = {
id: state.nextOopsId++,
description: params.description,
fixTime: params.fixTime || "—",
commitLink: params.commitLink || "#commit",
timestamp: now,
};
state.oops.push(entry);
return {
content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
details: { state: { ...state, oops: [...state.oops] } },
};
}
case "list": {
if (state.oops.length === 0) {
return {
content: [{ type: "text", text: "No oops entries. Clean run so far." }],
details: { state: { ...state } },
};
}
const lines = state.oops.map(o =>
`#${o.id} ${o.description} — fixed in ${o.fixTime}`
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { state: { ...state } },
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: { state: { ...state } },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("💥 oops "));
text += theme.fg("muted", args.action || "");
if (args.description) text += " " + theme.fg("dim", `"${args.description}"`);
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
const text = result.content[0];
return new Text(theme.fg("warning", text?.type === "text" ? text.text : ""), 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_deploy
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_deploy",
label: "Deploy Calvana",
description: `Regenerate the /live page with current ship log and deploy to https://${DEPLOY_CONFIG.domain}. Call this after adding/updating ships or oops entries to push changes live.`,
parameters: DeployParams,
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
onUpdate?.({ content: [{ type: "text", text: "Generating HTML..." }] });
const liveHtml = generateLivePageHtml(state);
if (params.dryRun) {
return {
content: [{ type: "text", text: `Dry run — generated ${liveHtml.length} bytes of HTML.\n\n${liveHtml.slice(0, 500)}...` }],
details: { state: { ...state }, dryRun: true },
};
}
onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] });
try {
// Write HTML to server via SSH + incus exec
const escapedHtml = liveHtml.replace(/'/g, "'\\''");
const sshCmd = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
const writeCmd = `${sshCmd} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cat > ${DEPLOY_CONFIG.sitePath}/live/index.html << '\\''HTMLEOF'\\''
${liveHtml}
HTMLEOF
'"`;
// Use base64 to avoid all escaping nightmares
const b64Html = Buffer.from(liveHtml).toString("base64");
const deployResult = await pi.exec("bash", ["-c",
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
], { signal, timeout: 30000 });
if (deployResult.code !== 0) {
return {
content: [{ type: "text", text: `Deploy failed: ${deployResult.stderr}` }],
details: { state: { ...state }, error: deployResult.stderr },
isError: true,
};
}
// Rebuild and update docker service
const rebuildResult = await pi.exec("bash", ["-c",
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"`
], { signal, timeout: 60000 });
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
state.lastDeployed = now;
return {
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${rebuildResult.stdout}` }],
details: { state: { ...state, lastDeployed: now } },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Deploy error: ${err.message}` }],
details: { state: { ...state }, error: err.message },
isError: true,
};
}
},
renderCall(_args, theme) {
return new Text(theme.fg("toolTitle", theme.bold("🌐 deploy calvana")), 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as { error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `${details.error}`), 0, 0);
return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// COMMAND: /ships
// ════════════════════════════════════════════════════════════════
pi.registerCommand("ships", {
description: "View current Calvana shipping log",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new ShipLogComponent(state, theme, () => done());
});
},
});
// ════════════════════════════════════════════════════════════════
// COMMAND: /ship-deploy
// ════════════════════════════════════════════════════════════════
pi.registerCommand("ship-deploy", {
description: "Force deploy the Calvana site with current ship log",
handler: async (_args, ctx) => {
const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`);
if (!ok) return;
// Queue a deploy via the LLM
pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" });
},
});
}
// ════════════════════════════════════════════════════════════════════
// UI COMPONENT: /ships viewer
// ════════════════════════════════════════════════════════════════════
class ShipLogComponent {
private state: ShipLogState;
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(state: ShipLogState, theme: Theme, onClose: () => void) {
this.state = state;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
const lines: string[] = [];
const th = this.theme;
lines.push("");
lines.push(truncateToWidth(
th.fg("borderMuted", "─".repeat(3)) +
th.fg("accent", " 🚀 Calvana Ship Log ") +
th.fg("borderMuted", "─".repeat(Math.max(0, width - 26))),
width
));
lines.push("");
// Ships
if (this.state.ships.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width));
} else {
const shipped = this.state.ships.filter(s => s.status === "shipped").length;
lines.push(truncateToWidth(
` ${th.fg("muted", `${shipped}/${this.state.ships.length} shipped`)}`,
width
));
lines.push("");
for (const s of this.state.ships) {
const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ")
: s.status === "shipping" ? th.fg("warning", "● SHIPPING")
: th.fg("dim", "○ PLANNED ");
lines.push(truncateToWidth(
` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`,
width
));
lines.push(truncateToWidth(
` ${th.fg("dim", s.timestamp)} · ${th.fg("dim", s.metric)}`,
width
));
}
}
// Oops
if (this.state.oops.length > 0) {
lines.push("");
lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width));
for (const o of this.state.oops) {
lines.push(truncateToWidth(
` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`,
width
));
}
}
lines.push("");
if (this.state.lastDeployed) {
lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.state.lastDeployed}`)}`, width));
}
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
// ════════════════════════════════════════════════════════════════════
// HTML GENERATOR — Builds the /live page from current state
// ════════════════════════════════════════════════════════════════════
function generateLivePageHtml(state: ShipLogState): string {
const now = new Date().toISOString();
const shipCards = state.ships.map(s => {
const badgeClass = s.status === "shipped" ? "badge-shipped"
: s.status === "shipping" ? "badge-shipping"
: "badge-planned";
const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
const titleSuffix = s.status === "shipped" ? " ✓" : "";
return ` <div class="card">
<div class="card-header">
<span class="card-title">${escapeHtml(s.title)}${titleSuffix}</span>
<span class="badge ${badgeClass}">${badgeLabel}</span>
</div>
<p class="card-meta">⏱ ${escapeHtml(s.timestamp)}</p>
<p class="metric">What moved: ${escapeHtml(s.metric)}</p>
<div class="card-links"><a href="${escapeHtml(s.prLink)}">PR</a><a href="${escapeHtml(s.deployLink)}">Deploy</a><a href="${escapeHtml(s.loomLink)}">Loom clip</a></div>
</div>`;
}).join("\n");
const oopsEntries = state.oops.map(o => {
return ` <div class="oops-entry">
<span>${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""}</span>
<a href="${escapeHtml(o.commitLink)}">→ commit</a>
</div>`;
}).join("\n");
// If no ships yet, show placeholder
const shipsSection = state.ships.length > 0 ? shipCards : ` <div class="card">
<div class="card-header">
<span class="card-title">Warming up...</span>
<span class="badge badge-planned">Planned</span>
</div>
<p class="card-meta">⏱ —</p>
<p class="metric">What moved: —</p>
</div>`;
const oopsSection = state.oops.length > 0 ? oopsEntries : ` <div class="oops-entry">
<span>Nothing broken yet. Give it time.</span>
<a href="#commit">→ waiting</a>
</div>`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calvana — Live Shipping Log</title>
<meta name="description" content="Intentional chaos. Full receipts. Watch the build happen in real time.">
<meta property="og:title" content="Calvana — Live Shipping Log">
<meta property="og:description" content="Intentional chaos. Full receipts.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://${DEPLOY_CONFIG.domain}/live">
<meta name="twitter:card" content="summary">
<link rel="canonical" href="https://${DEPLOY_CONFIG.domain}/live">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="logo">calvana<span>.exe</span></a>
<div class="nav-links">
<a href="/manifesto">/manifesto</a>
<a href="/live" class="active">/live</a>
<a href="/hire">/hire</a>
</div>
</div>
</nav>
<main class="page">
<h1 class="hero-title">Live Shipping Log</h1>
<p class="subtitle">Intentional chaos. Full receipts.</p>
<section class="section">
<h2>Today's Ships</h2>
<div class="card-grid">
${shipsSection}
</div>
</section>
<section class="section">
<div class="two-col">
<div class="col col-broke">
<h3>Rules I broke today</h3>
<ul>
<li>Didn't ask permission</li>
<li>Didn't wait for alignment</li>
<li>Didn't write a PRD</li>
<li>Didn't submit a normal application</li>
</ul>
</div>
<div class="col col-kept">
<h3>Rules I refuse to break</h3>
<ul>
<li>No silent failures</li>
<li>No unbounded AI spend</li>
<li>No hallucinations shipped to users</li>
<li>No deploy without rollback path</li>
</ul>
</div>
</div>
</section>
<section class="section">
<h2>Oops Log</h2>
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
<div class="oops-log">
${oopsSection}
</div>
</section>
<footer>
<p class="footer-tagline">${SITE_CONFIG.tagline}</p>
<p style="margin-top:.4rem">Last updated: ${now}</p>
</footer>
</main>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -1,6 +0,0 @@
{
"theme": "synthwave",
"prompts": [
"../.claude/commands"
]
}

View File

@@ -1,120 +0,0 @@
---
name: bowser
description: Headless browser automation using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, web scraping, or browser automation that can run in the background. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel.
allowed-tools: Bash
---
# Playwright Bowser
## Purpose
Automate browsers using `playwright-cli` (via `@playwright/cli`) — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context.
## Prerequisites
Ensure the package is installed in the project:
```bash
bun add -d @playwright/cli
bunx playwright install chromium
```
## Key Details
- **Headless by default** — pass `--headed` to `open` to see the browser
- **Parallel sessions** — use `-s=<name>` to run multiple independent browser instances
- **Persistent profiles** — cookies and storage state preserved between calls
- **Token-efficient** — CLI-based, no accessibility trees or tool schemas in context
- **Vision mode** (opt-in) — set `PLAYWRIGHT_MCP_CAPS=vision` to receive screenshots as image responses in context instead of just saving to disk
## Sessions
**Always use a named session.** Derive a short, descriptive kebab-case name from the user's prompt. This gives each task a persistent browser profile (cookies, localStorage, history) that accumulates across calls.
```bash
# Derive session name from prompt context:
# "test the checkout flow on mystore.com" → -s=mystore-checkout
# "scrape pricing from competitor.com" → -s=competitor-pricing
# "UI test the login page" → -s=login-ui-test
bunx playwright-cli -s=mystore-checkout open https://mystore.com --persistent
bunx playwright-cli -s=mystore-checkout snapshot
bunx playwright-cli -s=mystore-checkout click e12
```
Managing sessions:
```bash
bunx playwright-cli list # list all sessions
bunx playwright-cli close-all # close all sessions
bunx playwright-cli -s=<name> close # close specific session
bunx playwright-cli -s=<name> delete-data # wipe session profile
```
## Quick Reference
```
Core: open [url], goto <url>, click <ref>, fill <ref> <text>, type <text>, snapshot, screenshot [ref], close
Navigate: go-back, go-forward, reload
Keyboard: press <key>, keydown <key>, keyup <key>
Mouse: mousemove <x> <y>, mousedown, mouseup, mousewheel <dx> <dy>
Tabs: tab-list, tab-new [url], tab-close [index], tab-select <index>
Save: screenshot [ref], pdf, screenshot --filename=f
Storage: state-save, state-load, cookie-*, localstorage-*, sessionstorage-*
Network: route <pattern>, route-list, unroute, network
DevTools: console, run-code <code>, tracing-start/stop, video-start/stop
Sessions: -s=<name> <cmd>, list, close-all, kill-all
Config: open --headed, open --browser=chrome, resize <w> <h>
```
## Workflow
1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch:
```bash
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent
# or headed:
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent --headed
# or with vision (screenshots returned as image responses in context):
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision bunx playwright-cli -s=<session-name> open <url> --persistent
```
3. Get element references via snapshot:
```bash
bunx playwright-cli snapshot
```
4. Interact using refs from snapshot:
```bash
bunx playwright-cli click <ref>
bunx playwright-cli fill <ref> "text"
bunx playwright-cli type "text"
bunx playwright-cli press Enter
```
5. Capture results:
```bash
bunx playwright-cli screenshot
bunx playwright-cli screenshot --filename=output.png
```
6. **Always close the session when done.** This is not optional — close the named session after finishing your task:
```bash
bunx playwright-cli -s=<session-name> close
```
## Configuration
If a `playwright-cli.json` exists in the working directory, use it automatically. If the user provides a path to a config file, use `--config path/to/config.json`. Otherwise, skip configuration — the env var and CLI defaults are sufficient.
```json
{
"browser": {
"browserName": "chromium",
"launchOptions": { "headless": true },
"contextOptions": { "viewport": { "width": 1440, "height": 900 } }
},
"outputDir": "./screenshots"
}
```
## Full Help
Run `bunx playwright-cli --help` or `bunx playwright-cli --help <command>` for detailed command usage.

View File

@@ -1,86 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "catppuccin-mocha",
"vars": {
"bg": "#1e1e2e",
"bgDark": "#181825",
"bgDeep": "#13131e",
"surface": "#2a2a3c",
"selection": "#34344a",
"bgRed": "#2e1420",
"bgGreen": "#142218",
"bgPeach": "#2e2010",
"bgBlue": "#141e38",
"bgMauve": "#261840",
"bgTeal": "#122830",
"comment": "#d5bcff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#ff7eb3",
"maroon": "#ffa0b8",
"peach": "#ffb370",
"yellow": "#ffe585",
"green": "#7af5a0",
"teal": "#60f0d8",
"sky": "#6ae4ff",
"sapphire": "#5cceff",
"blue": "#7db8ff",
"lavender": "#bfb8ff",
"mauve": "#d9a0ff",
"flamingo": "#ffc4c4",
"pink": "#ffb0e0"
},
"colors": {
"accent": "mauve",
"border": "selection",
"borderAccent": "mauve",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgMauve",
"userMessageBg": "bgBlue",
"userMessageText": "fg",
"customMessageBg": "bgTeal",
"customMessageText": "fg",
"customMessageLabel": "teal",
"toolPendingBg": "bgPeach",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "peach",
"toolOutput": "fgSoft",
"mdHeading": "peach",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "sky",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "mauve",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "mauve",
"syntaxFunction": "blue",
"syntaxVariable": "pink",
"syntaxString": "green",
"syntaxNumber": "peach",
"syntaxType": "sky",
"syntaxOperator": "lavender",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "sky",
"thinkingHigh": "mauve",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}

View File

@@ -1,81 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "cyberpunk",
"vars": {
"bg": "#0a0a14",
"bgDark": "#06060e",
"bgDeep": "#040410",
"surface": "#12122a",
"selection": "#1a1a38",
"bgRed": "#2a0a12",
"bgOrange": "#2a1408",
"bgSky": "#081a30",
"bgCyan": "#0a2228",
"bgWarm": "#220a30",
"bgPink": "#2a0a22",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#ffe600",
"yellow": "#ffe600",
"cyan": "#00e5ff",
"magenta": "#ff00aa",
"red": "#ff1744",
"green": "#00e676",
"purple": "#aa00ff",
"blue": "#2979ff",
"orange": "#ff6d00"
},
"colors": {
"accent": "cyan",
"border": "magenta",
"borderAccent": "yellow",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "green",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "yellow",
"toolOutput": "fgSoft",
"mdHeading": "magenta",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "yellow",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "cyan",
"syntaxVariable": "yellow",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "blue",
"syntaxOperator": "magenta",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "magenta",
"bashMode": "orange"
}
}

View File

@@ -1,81 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "dracula",
"vars": {
"bg": "#1a1b26",
"bgDark": "#161722",
"bgDeep": "#141520",
"surface": "#252738",
"selection": "#2c2e44",
"bgRed": "#2e1220",
"bgOrange": "#2e1c12",
"bgGreen": "#122e1a",
"bgCyan": "#122a2e",
"bgPurple": "#261536",
"bgPink": "#2e1228",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f8fcc4",
"cyan": "#8be9fd",
"green": "#50fa7b",
"orange": "#ffb86c",
"pink": "#ff79c6",
"purple": "#bd93f9",
"red": "#ff5555",
"yellow": "#f1fa8c",
"blue": "#6296e4"
},
"colors": {
"accent": "purple",
"border": "pink",
"borderAccent": "purple",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "cyan",
"selectedBg": "bgPurple",
"userMessageBg": "bgPink",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "pink",
"toolOutput": "fgSoft",
"mdHeading": "pink",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "pink",
"syntaxFunction": "green",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "cyan",
"syntaxOperator": "pink",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "everforest",
"vars": {
"bg": "#191f1d",
"bgDark": "#141a18",
"bg1": "#1e2522",
"bg2": "#222a28",
"surface": "#2c3532",
"selection": "#323e3a",
"bgRed": "#301718",
"bgOrange": "#302217",
"bgSky": "#192b34",
"bgCyan": "#172b26",
"bgWarm": "#351d29",
"bgPink": "#311c31",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#e7f4cd",
"red": "#eb7073",
"orange": "#f1a27e",
"yellow": "#eed096",
"green": "#bde481",
"aqua": "#78e292",
"teal": "#52e0bd",
"blue": "#78c8e2",
"purple": "#e689b5"
},
"colors": {
"accent": "green",
"border": "aqua",
"borderAccent": "green",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgCyan",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgSky",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "green",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "aqua",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "teal",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "green",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "green",
"syntaxVariable": "blue",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "aqua",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "green",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}

View File

@@ -1,80 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "gruvbox",
"vars": {
"bg": "#221f1c",
"bgDark": "#1c1a17",
"bgDeep": "#171412",
"surface": "#322d29",
"selection": "#3f3731",
"bgRed": "#341714",
"bgOrange": "#322215",
"bgSky": "#152432",
"bgCyan": "#142924",
"bgWarm": "#322b15",
"bgPink": "#321524",
"comment": "#fcebc5",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fb4b37",
"green": "#ebed5e",
"yellow": "#fcd783",
"blue": "#67a6e4",
"purple": "#ca74e7",
"aqua": "#81e4be",
"orange": "#fd953f"
},
"colors": {
"accent": "orange",
"border": "yellow",
"borderAccent": "orange",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "aqua",
"selectedBg": "bgWarm",
"userMessageBg": "bgOrange",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgSky",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "aqua",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "blue",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "orange",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "aqua",
"syntaxVariable": "blue",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "yellow",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "aqua",
"thinkingHigh": "yellow",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}

View File

@@ -1,76 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "midnight-ocean",
"vars": {
"deepBlue": "#0a192f",
"oceanBlue": "#0077be",
"teal": "#00ced1",
"cyan": "#4fd1ed",
"softWhite": "#e6f1ff",
"mutedBlue": "#233554",
"lightMutedBlue": "#a8b2d1",
"slate": "#8892b0",
"successGreen": "#64ffda",
"errorRed": "#ff5f56",
"warningAmber": "#ffd700",
"purple": "#c678dd"
},
"colors": {
"accent": "oceanBlue",
"border": "mutedBlue",
"borderAccent": "teal",
"borderMuted": 236,
"success": "successGreen",
"error": "errorRed",
"warning": "warningAmber",
"muted": "slate",
"dim": 240,
"text": "softWhite",
"thinkingText": "teal",
"selectedBg": "#112240",
"userMessageBg": "#112240",
"userMessageText": "softWhite",
"customMessageBg": "#112240",
"customMessageText": "softWhite",
"customMessageLabel": "teal",
"toolPendingBg": "deepBlue",
"toolSuccessBg": "#0d2521",
"toolErrorBg": "#331616",
"toolTitle": "cyan",
"toolOutput": "lightMutedBlue",
"mdHeading": "teal",
"mdLink": "oceanBlue",
"mdLinkUrl": "slate",
"mdCode": "cyan",
"mdCodeBlock": "#011627",
"mdCodeBlockBorder": "mutedBlue",
"mdQuote": "slate",
"mdQuoteBorder": "mutedBlue",
"mdHr": "mutedBlue",
"mdListBullet": "teal",
"toolDiffAdded": "successGreen",
"toolDiffRemoved": "errorRed",
"toolDiffContext": "slate",
"syntaxComment": "slate",
"syntaxKeyword": "purple",
"syntaxFunction": "teal",
"syntaxVariable": "cyan",
"syntaxString": "successGreen",
"syntaxNumber": "warningAmber",
"syntaxType": "oceanBlue",
"syntaxOperator": "teal",
"syntaxPunctuation": "lightMutedBlue",
"thinkingOff": "mutedBlue",
"thinkingMinimal": "oceanBlue",
"thinkingLow": "teal",
"thinkingMedium": "cyan",
"thinkingHigh": "warningAmber",
"thinkingXhigh": "errorRed",
"bashMode": "warningAmber"
},
"export": {
"pageBg": "#0a192f",
"cardBg": "#112240",
"infoBg": "#0077be"
}
}

View File

@@ -1,84 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "nord",
"vars": {
"bg": "#1a1d23",
"bgDark": "#15181d",
"bgDeep": "#111316",
"surface": "#272b34",
"selection": "#2f3541",
"bgRed": "#2e1818",
"bgOrange": "#31241a",
"bgSky": "#1c2835",
"bgCyan": "#192c2d",
"bgWarm": "#291b30",
"bgPink": "#2d1927",
"comment": "#ccebf4",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"frost1": "#67e4e2",
"frost2": "#72cee8",
"frost3": "#67a5e4",
"frost4": "#5c97df",
"red": "#e85e6c",
"orange": "#ed7f5e",
"yellow": "#f5d189",
"green": "#92df6b",
"purple": "#e278e2",
"border": "#3e5974",
"dim": "#3d4c5b"
},
"colors": {
"accent": "frost2",
"border": "border",
"borderAccent": "frost2",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "frost1",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "frost2",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "frost2",
"mdLinkUrl": "comment",
"mdCode": "frost1",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "frost2",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "frost3",
"syntaxFunction": "frost2",
"syntaxVariable": "fg",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "frost1",
"syntaxOperator": "frost3",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "dim",
"thinkingLow": "frost4",
"thinkingMedium": "frost3",
"thinkingHigh": "frost2",
"thinkingXhigh": "frost1",
"bashMode": "yellow"
}
}

View File

@@ -1,83 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "ocean-breeze",
"vars": {
"bg": "#0d1b2a",
"bgDark": "#0a1520",
"bgDeep": "#081018",
"surface": "#152a3e",
"selection": "#1b3450",
"bgRed": "#2a1018",
"bgOrange": "#2a1e10",
"bgSky": "#0e2440",
"bgCyan": "#0c2a2e",
"bgWarm": "#2a1530",
"bgPink": "#2e1028",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#c2faf2",
"coral": "#ff6b6b",
"amber": "#ffd166",
"kelp": "#2eeab5",
"biolum": "#33fff7",
"foam": "#50b0e0",
"spray": "#7ec8e3",
"mist": "#a8d8ea",
"sand": "#ecf49a",
"purple": "#b48aef",
"pink": "#f772b9"
},
"colors": {
"accent": "biolum",
"border": "foam",
"borderAccent": "biolum",
"borderMuted": "surface",
"success": "kelp",
"error": "coral",
"warning": "amber",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "biolum",
"selectedBg": "selection",
"userMessageBg": "bgSky",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "spray",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "spray",
"toolOutput": "fgSoft",
"mdHeading": "mist",
"mdLink": "biolum",
"mdLinkUrl": "comment",
"mdCode": "kelp",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "spray",
"toolDiffAdded": "kelp",
"toolDiffRemoved": "coral",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "coral",
"syntaxFunction": "biolum",
"syntaxVariable": "spray",
"syntaxString": "kelp",
"syntaxNumber": "amber",
"syntaxType": "purple",
"syntaxOperator": "foam",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "foam",
"thinkingMedium": "spray",
"thinkingHigh": "biolum",
"thinkingXhigh": "pink",
"bashMode": "amber"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "rose-pine",
"vars": {
"bg": "#1a1726",
"bgDark": "#161320",
"bgDeep": "#12101c",
"surface": "#242038",
"selection": "#2e2946",
"bgRed": "#2c1220",
"bgOrange": "#2a1c12",
"bgSky": "#122030",
"bgCyan": "#132a2e",
"bgWarm": "#2a1830",
"bgPink": "#301828",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f0a8be",
"love": "#f47a9e",
"gold": "#f8cc85",
"rose": "#f0c4c4",
"pine": "#50b8d8",
"foam": "#a8e0ea",
"iris": "#d4a8ff",
"orchid": "#e088d0",
"ember": "#f09060",
"green": "#78e0a0"
},
"colors": {
"accent": "iris",
"border": "orchid",
"borderAccent": "iris",
"borderMuted": "surface",
"success": "foam",
"error": "love",
"warning": "gold",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "foam",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "iris",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "gold",
"toolOutput": "fgSoft",
"mdHeading": "love",
"mdLink": "foam",
"mdLinkUrl": "comment",
"mdCode": "gold",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "rose",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "iris",
"toolDiffAdded": "green",
"toolDiffRemoved": "love",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "love",
"syntaxFunction": "foam",
"syntaxVariable": "fg",
"syntaxString": "gold",
"syntaxNumber": "iris",
"syntaxType": "pine",
"syntaxOperator": "orchid",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "pine",
"thinkingMedium": "iris",
"thinkingHigh": "foam",
"thinkingXhigh": "love",
"bashMode": "ember"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "synthwave",
"vars": {
"bg": "#262335",
"bgDark": "#241b2f",
"bgDeep": "#1e1d2d",
"surface": "#34294f",
"selection": "#463465",
"bgRed": "#3d1018",
"bgRedWarm": "#301510",
"bgOrange": "#2e1f10",
"bgSky": "#1a2e4a",
"bgCyan": "#152838",
"bgWarm": "#4a1e6a",
"bgPink": "#35153a",
"comment": "#fede5d",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fe4450",
"cyan": "#36f9f6",
"yellow": "#fede5d",
"pink": "#ff7edb",
"green": "#72f1b8",
"orange": "#ff8b39",
"purple": "#c792ea",
"blue": "#4d9de0"
},
"colors": {
"accent": "cyan",
"border": "pink",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "#4a9e6a",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "yellow",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "cyan",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "pink",
"syntaxType": "green",
"syntaxOperator": "cyan",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}

View File

@@ -1,83 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "tokyo-night",
"vars": {
"bg": "#1a1b26",
"bgDark": "#141520",
"bg1": "#1e2030",
"bg2": "#252840",
"surface": "#2a2d48",
"selection": "#353860",
"bgRed": "#301420",
"bgOrange": "#2e1e14",
"bgSky": "#162040",
"bgCyan": "#142530",
"bgWarm": "#301848",
"bgPink": "#2d1430",
"comment": "#90e8ff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"blue": "#7eaaff",
"cyan": "#72dfff",
"magenta": "#c9a5ff",
"purple": "#b48ef5",
"green": "#a8e06a",
"red": "#ff7a94",
"orange": "#ffa55c",
"yellow": "#f0c060",
"teal": "#20d4b0"
},
"colors": {
"accent": "blue",
"border": "purple",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "magenta",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "blue",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "blue",
"syntaxVariable": "purple",
"syntaxString": "green",
"syntaxNumber": "orange",
"syntaxType": "cyan",
"syntaxOperator": "teal",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "cyan",
"thinkingHigh": "magenta",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}

View File

@@ -1,20 +0,0 @@
# Pi vs CC — Extension Playground
Pi Coding Agent extension examples and experiments.
## Tooling
- **Package manager**: `bun` (not npm/yarn/pnpm)
- **Task runner**: `just` (see justfile)
- **Extensions run via**: `pi -e extensions/<name>.ts`
## Project Structure
- `extensions/` — Pi extension source files (.ts)
- `specs/` — Feature specifications
- `.pi/agents/` — Agent definitions for agent-team extension
- `.pi/agent-sessions/` — Ephemeral session files (gitignored)
## Conventions
- Extensions are standalone .ts files loaded by Pi's jiti runtime
- Available imports: `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@sinclair/typebox`, plus any deps in package.json
- Register tools at the top level of the extension function (not inside event handlers)
- Use `isToolCallEventType()` for type-safe tool_call event narrowing

289
README.md
View File

@@ -1,279 +1,24 @@
# pi-vs-cc
# Calvana
A collection of [Pi Coding Agent](https://github.com/mariozechner/pi-coding-agent) customized instances. _Why?_ To showcase what it looks like to hedge against the leader in the agentic coding market, Claude Code. Here we showcase how you can customize the UI, agent orchestration tools, safety auditing, and cross-agent integrations.
Application microsite deployed at [calvana.quikcue.com](https://calvana.quikcue.com).
<div align="center">
<img src="./images/pi-logo.png" alt="pi-vs-cc" width="700">
</div>
## Structure
---
```
html/
manifesto/index.html — /manifesto (main page)
live/index.html — /live (ship log)
hire/index.html — /hire (contact)
css/style.css — shared styles
404.html — custom 404
index.html — redirect to /manifesto
Dockerfile — nginx:alpine container
nginx.conf — nginx config
```
## Prerequisites
All three are required:
| Tool | Purpose | Install |
| --------------- | ------------------------- | ---------------------------------------------------------- |
| **Bun** ≥ 1.3.2 | Runtime & package manager | [bun.sh](https://bun.sh) |
| **just** | Task runner | `brew install just` |
| **pi** | Pi Coding Agent CLI | [Pi docs](https://github.com/mariozechner/pi-coding-agent) |
---
## API Keys
Pi does **not** auto-load `.env` files — API keys must be present in your shell's environment **before** you launch Pi. A sample file is provided:
## Deploy
```bash
cp .env.sample .env # copy the template
# open .env and fill in your keys
docker build -t calvana:latest .
docker service update --force calvana
```
`.env.sample` covers the four most popular providers:
| Provider | Variable | Get your key |
| ---------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
| OpenAI | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/api-keys) |
| Anthropic | `ANTHROPIC_API_KEY` | [console.anthropic.com](https://console.anthropic.com/settings/keys) |
| Google | `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
| OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai](https://openrouter.ai/keys) |
| Many Many Others | `***` | [Pi Providers docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) |
### Sourcing your keys
Pick whichever approach fits your workflow:
**Option A — Source manually each session:**
```bash
source .env && pi
```
**Option B — One-liner alias (add to `~/.zshrc` or `~/.bashrc`):**
```bash
alias pi='source $(pwd)/.env && pi'
```
**Option C — Use the `just` task runner (auto-wired via `set dotenv-load`):**
```bash
just pi # .env is loaded automatically for every just recipe
just ext-minimal # works for all recipes, not just `pi`
```
---
## Installation
```bash
bun install
```
---
## Extensions
| Extension | File | Description |
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **pure-focus** | `extensions/pure-focus.ts` | Removes the footer bar and status line entirely — pure distraction-free mode |
| **minimal** | `extensions/minimal.ts` | Compact footer showing model name and a 10-block context usage meter `[###-------] 30%` |
| **cross-agent** | `extensions/cross-agent.ts` | Scans `.claude/`, `.gemini/`, `.codex/` dirs for commands, skills, and agents and registers them in Pi |
| **purpose-gate** | `extensions/purpose-gate.ts` | Prompts you to declare session intent on startup; shows a persistent purpose widget and blocks prompts until answered |
| **tool-counter** | `extensions/tool-counter.ts` | Rich two-line footer: model + context meter + token/cost stats on line 1, cwd/branch + per-tool call tally on line 2 |
| **tool-counter-widget** | `extensions/tool-counter-widget.ts` | Live-updating above-editor widget showing per-tool call counts with background colors |
| **subagent-widget** | `extensions/subagent-widget.ts` | `/sub <task>` command that spawns background Pi subagents; each gets its own streaming live-progress widget |
| **tilldone** | `extensions/tilldone.ts` | Task discipline system — define tasks before starting work; tracks completion state across steps; shows persistent task list in footer with live progress |
| **agent-team** | `extensions/agent-team.ts` | Dispatcher-only orchestrator: the primary agent delegates all work to named specialist agents via `dispatch_agent`; shows a grid dashboard |
| **system-select** | `extensions/system-select.ts` | `/system` command to interactively switch between agent personas/system prompts from `.pi/agents/`, `.claude/agents/`, `.gemini/agents/`, `.codex/agents/` |
| **damage-control** | `extensions/damage-control.ts` | Real-time safety auditing — intercepts dangerous bash patterns and enforces path-based access controls from `.pi/damage-control-rules.yaml` |
| **agent-chain** | `extensions/agent-chain.ts` | Sequential pipeline orchestrator — chains multiple agents where each step's output feeds into the next step's prompt; use `/chain` to select and run |
| **agent-dashboard** | `extensions/agent-dashboard.ts` | Unified agent observability — passively tracks `dispatch_agent`, `subagent_create`, and `run_chain` across all orchestration interfaces; compact widget + `/dashboard` overlay with live, history, interface, and stats views |
| **pi-pi** | `extensions/pi-pi.ts` | Meta-agent that builds Pi agents using parallel research experts for documentation |
| **session-replay** | `extensions/session-replay.ts` | Scrollable timeline overlay of session history - showcasing customizable dialog UI |
| **theme-cycler** | `extensions/theme-cycler.ts` | Keyboard shortcuts (Ctrl+X/Ctrl+Q) and `/theme` command to cycle/switch between custom themes |
---
## Usage
### Run a single extension
```bash
pi -e extensions/<name>.ts
```
### Stack multiple extensions
Extensions compose — pass multiple `-e` flags:
```bash
pi -e extensions/minimal.ts -e extensions/cross-agent.ts
```
### Use `just` recipes
`just` wraps the most useful combinations. Run `just` with no arguments to list all available recipes:
```bash
just
```
Common recipes:
```bash
just pi # Plain Pi, no extensions
just ext-pure-focus # Distraction-free mode
just ext-minimal # Minimal context meter footer
just ext-cross-agent # Cross-agent command loading + minimal footer
just ext-purpose-gate # Purpose gate + minimal footer
just ext-tool-counter # Rich two-line footer with tool tally
just ext-tool-counter-widget # Per-tool widget above the editor
just ext-subagent-widget # Subagent spawner with live progress widgets
just ext-tilldone # Task discipline system with live progress tracking
just ext-agent-team # Multi-agent orchestration grid dashboard
just ext-system-select # Agent persona switcher via /system command
just ext-damage-control # Safety auditing + minimal footer
just ext-agent-chain # Sequential pipeline orchestrator with step chaining
just ext-agent-dashboard # Unified agent monitoring across team, subagent, and chain
just ext-pi-pi # Meta-agent that builds Pi agents using parallel experts
just ext-session-replay # Scrollable timeline overlay of session history
just ext-theme-cycler # Theme cycler + minimal footer
just all # Open every extension in its own terminal window
```
The `open` recipe allows you to spin up a new terminal window with any combination of stacked extensions (omit `.ts`):
```bash
just open purpose-gate minimal tool-counter-widget
```
---
## Project Structure
```
pi-vs-cc/
├── extensions/ # Pi extension source files (.ts) — one file per extension
├── specs/ # Feature specifications for extensions
├── .pi/
│ ├── agent-sessions/ # Ephemeral session files (gitignored)
│ ├── agents/ # Agent definitions for team and chain extensions
│ │ ├── pi-pi/ # Expert agents for the pi-pi meta-agent
│ │ ├── agent-chain.yaml # Pipeline definition for agent-chain
│ │ ├── teams.yaml # Team definition for agent-team
│ │ └── *.md # Individual agent persona/system prompts
│ ├── skills/ # Custom skills
│ ├── themes/ # Custom themes (.json) used by theme-cycler
│ ├── damage-control-rules.yaml # Path/command rules for safety auditing
│ └── settings.json # Pi workspace settings
├── justfile # just task definitions
├── CLAUDE.md # Conventions and tooling reference (for agents)
├── THEME.md # Color token conventions for extension authors
└── TOOLS.md # Built-in tool function signatures available in extensions
```
---
## Orchestrating Multi-Agent Workflows
Pi's architecture makes it easy to coordinate multiple autonomous agents. This playground includes several powerful multi-agent extensions:
### Subagent Widget (`/sub`)
The `subagent-widget` extension allows you to offload isolated tasks to background Pi agents while you continue working in the main terminal. Typing `/sub <task>` spawns a headless subagent that reports its streaming progress via a persistent, live-updating UI widget above your editor.
### Agent Teams (`/team`)
The `agent-team` orchestrator operates as a dispatcher. Instead of answering prompts directly, the primary agent reviews your request, selects a specialist from a defined roster, and delegates the work via a `dispatch_agent` tool.
- Teams are configured in `.pi/agents/teams.yaml` where each top-level key is a team name containing a list of agent names (e.g., `frontend: [planner, builder, bowser]`).
- Individual agent personas (e.g., `builder.md`, `reviewer.md`) live in `.pi/agents/`.
- **pi-pi Meta-Agent**: The `pi-pi` team specifically delegates tasks to specialized Pi framework experts (`ext-expert.md`, `theme-expert.md`, `tui-expert.md`) located in `.pi/agents/pi-pi/` to build high-quality Pi extensions using parallel research.
- **Web Crawling Fallbacks**: To ingest the latest framework documentation dynamically, these experts use `firecrawl` as their default modern page crawler, but are explicitly programmed to safely fall back to the native `curl` baked into their bash toolset if Firecrawl fails or is unavailable.
### Agent Chains (`/chain`)
Unlike the dynamic dispatcher, `agent-chain` acts as a sequential pipeline orchestrator. Workflows are defined in `.pi/agents/agent-chain.yaml` where the output of one agent becomes the input (`$INPUT`) to the next.
- Workflows are defined as a list of `steps`, where each step specifies an `agent` and a `prompt`.
- The `$INPUT` variable injects the previous step's output (or the user's initial prompt for the first step), and `$ORIGINAL` always contains the user's initial prompt.
- Example: The `plan-build-review` pipeline feeds your prompt to the `planner`, passes the plan to the `builder`, and finally sends the code to the `reviewer`.
### Agent Dashboard (`/dashboard`)
The `agent-dashboard` extension provides unified observability across all three orchestration interfaces. It passively intercepts `dispatch_agent`, `subagent_create`, `subagent_continue`, and `run_chain` tool calls and tracks every agent run. Stack it alongside any orchestration extension:
```bash
pi -e extensions/agent-team.ts -e extensions/agent-dashboard.ts
```
The compact widget shows active/done/error counts. Use `/dashboard` to open a full-screen overlay with four views: **Live** (active agent cards), **History** (completed runs table), **Interfaces** (grouped by team/subagent/chain), and **Stats** (aggregate metrics and per-agent durations).
---
## Safety Auditing & Damage Control
The `damage-control` extension provides real-time security hooks to prevent catastrophic mistakes when agents execute bash commands or modify files. It uses Pi's `tool_call` event to intercept and evaluate every action against `.pi/damage-control-rules.yaml`.
- **Dangerous Commands**: Uses regex (`bashToolPatterns`) to block destructive commands like `rm -rf`, `git reset --hard`, `aws s3 rm --recursive`, or `DROP DATABASE`. Some rules strictly block execution, while others (`ask: true`) pause execution to prompt you for confirmation.
- **Zero Access Paths**: Prevents the agent from reading or writing sensitive files (e.g., `.env`, `~/.ssh/`, `*.pem`).
- **Read-Only Paths**: Allows reading but blocks modifying system files or lockfiles (`package-lock.json`, `/etc/`).
- **No-Delete Paths**: Allows modifying but prevents deleting critical project configuration (`.git/`, `Dockerfile`, `README.md`).
---
## Extension Author Reference
Companion docs cover the conventions used across all extensions in this repo:
- **[COMPARISON.md](COMPARISON.md)** — Feature-by-feature comparison of Claude Code vs Pi Agent across 12 categories (design philosophy, tools, hooks, SDK, enterprise, and more).
- **[PI_VS_OPEN_CODE.md](PI_VS_OPEN_CODE.md)** — Architectural comparison of Pi Agent vs OpenCode (open-source Claude Code alternative) focusing on extension capabilities, event lifecycle, and UI customization.
- **[RESERVED_KEYS.md](RESERVED_KEYS.md)** — Pi reserved keybindings, overridable keys, and safe keys for extension authors.
- **[THEME.md](THEME.md)** — Color language: which Pi theme tokens (`success`, `accent`, `warning`, `dim`, `muted`) map to which UI roles, with examples.
- **[TOOLS.md](TOOLS.md)** — Function signatures for the built-in tools available inside extensions (`read`, `bash`, `edit`, `write`).
---
## Hooks & Events
Side-by-side comparison of lifecycle hooks in [Claude Code](https://docs.anthropic.com/en/docs/claude-code/hooks) vs [Pi Agent](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md#events).
| Category | Claude Code | Pi Agent | Available In |
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ |
| **Session** | `SessionStart`, `SessionEnd` | `session_start`, `session_shutdown` | Both |
| **Input** | `UserPromptSubmit` | `input` | Both |
| **Tool** | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | `tool_call`, `tool_result`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | Both |
| **Bash** | — | `BashSpawnHook`, `user_bash` | Pi |
| **Permission** | `PermissionRequest` | — | CC |
| **Compact** | `PreCompact` | `session_before_compact`, `session_compact` | Both |
| **Branching** | — | `session_before_fork`, `session_fork`, `session_before_switch`, `session_switch`, `session_before_tree`, `session_tree` | Pi |
| **Agent / Turn** | — | `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end` | Pi |
| **Message** | — | `message_start`, `message_update`, `message_end` | Pi |
| **Model / Context** | — | `model_select`, `context` | Pi |
| **Sub-agents** | `SubagentStart`, `SubagentStop`, `TeammateIdle`, `TaskCompleted` | — | CC |
| **Config** | `ConfigChange` | — | CC |
| **Worktree** | `WorktreeCreate`, `WorktreeRemove` | — | CC |
| **System** | `Stop`, `Notification` | — | CC |
## Resources
## Pi Documentation
| Doc | Description |
| ------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [Mario's Twitter](https://x.com/badlogicgames) | Creator of Pi Coding Agent |
| [README.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md) | Overview and getting started |
| [sdk.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md) | TypeScript SDK reference |
| [rpc.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md) | RPC protocol specification |
| [json.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/json.md) | JSON event stream format |
| [providers.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) | API keys and provider setup |
| [models.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md) | Custom models (Ollama, vLLM, etc.) |
| [extensions.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md) | Extension system |
| [skills.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md) | Skills (Agent Skills standard) |
| [settings.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md) | Configuration |
| [compaction.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md) | Context compaction |
## Master Agentic Coding
> Prepare for the future of software engineering
Learn tactical agentic coding patterns with [Tactical Agentic Coding](https://agenticengineer.com/tactical-agentic-coding?y=pivscc)
Follow the [IndyDevDan YouTube channel](https://www.youtube.com/@indydevdan) to improve your agentic coding advantage.

View File

@@ -1,28 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "pi-vs-cc",
"dependencies": {
"yaml": "^2.8.0",
},
"devDependencies": {
"@playwright/cli": "^0.1.1",
},
},
},
"packages": {
"@playwright/cli": ["@playwright/cli@0.1.1", "", { "dependencies": { "minimist": "^1.2.5", "playwright": "1.59.0-alpha-1771104257000" }, "bin": { "playwright-cli": "playwright-cli.js" } }, "sha512-9k11ZfDwAfMVDDIuEVW1Wvs8SoDNXIY1dNQ+9C9/SS8ZmElkcxesu5eoL7vNa96ntibUGaq1TM2qQoqvdl/I9g=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"playwright": ["playwright@1.59.0-alpha-1771104257000", "", { "dependencies": { "playwright-core": "1.59.0-alpha-1771104257000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg=="],
"playwright-core": ["playwright-core@1.59.0-alpha-1771104257000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
}
}

View File

@@ -1,797 +0,0 @@
/**
* Agent Chain — Sequential pipeline orchestrator
*
* Runs opinionated, repeatable agent workflows. Chains are defined in
* .pi/agents/agent-chain.yaml — each chain is a sequence of agent steps
* with prompt templates. The user's original prompt flows into step 1,
* the output becomes $INPUT for step 2's prompt template, and so on.
* $ORIGINAL is always the user's original prompt.
*
* The primary Pi agent has NO codebase tools — it can ONLY kick off the
* pipeline via the `run_chain` tool. On boot you select a chain; the
* agent decides when to run it based on the user's prompt.
*
* Agents maintain session context within a Pi session — re-running the
* chain lets each agent resume where it left off.
*
* Commands:
* /chain — switch active chain
* /chain-list — list all available chains
*
* Usage: pi -e extensions/agent-chain.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
import { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ────────────────────────────────────────
interface ChainStep {
agent: string;
prompt: string;
}
interface ChainDef {
name: string;
description: string;
steps: ChainStep[];
}
interface AgentDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
}
interface StepState {
agent: string;
status: "pending" | "running" | "done" | "error";
elapsed: number;
lastWork: string;
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// ── Chain YAML Parser ────────────────────────────
function parseChainYaml(raw: string): ChainDef[] {
const chains: ChainDef[] = [];
let current: ChainDef | null = null;
let currentStep: ChainStep | null = null;
for (const line of raw.split("\n")) {
// Chain name: top-level key
const chainMatch = line.match(/^(\S[^:]*):$/);
if (chainMatch) {
if (current && currentStep) {
current.steps.push(currentStep);
currentStep = null;
}
current = { name: chainMatch[1].trim(), description: "", steps: [] };
chains.push(current);
continue;
}
// Chain description
const descMatch = line.match(/^\s+description:\s+(.+)$/);
if (descMatch && current && !currentStep) {
let desc = descMatch[1].trim();
if ((desc.startsWith('"') && desc.endsWith('"')) ||
(desc.startsWith("'") && desc.endsWith("'"))) {
desc = desc.slice(1, -1);
}
current.description = desc;
continue;
}
// "steps:" label — skip
if (line.match(/^\s+steps:\s*$/) && current) {
continue;
}
// Step agent line
const agentMatch = line.match(/^\s+-\s+agent:\s+(.+)$/);
if (agentMatch && current) {
if (currentStep) {
current.steps.push(currentStep);
}
currentStep = { agent: agentMatch[1].trim(), prompt: "" };
continue;
}
// Step prompt line
const promptMatch = line.match(/^\s+prompt:\s+(.+)$/);
if (promptMatch && currentStep) {
let prompt = promptMatch[1].trim();
if ((prompt.startsWith('"') && prompt.endsWith('"')) ||
(prompt.startsWith("'") && prompt.endsWith("'"))) {
prompt = prompt.slice(1, -1);
}
prompt = prompt.replace(/\\n/g, "\n");
currentStep.prompt = prompt;
continue;
}
}
if (current && currentStep) {
current.steps.push(currentStep);
}
return chains;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string): AgentDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
if (!frontmatter.name) return null;
return {
name: frontmatter.name,
description: frontmatter.description || "",
tools: frontmatter.tools || "read,grep,find,ls",
systemPrompt: match[2].trim(),
};
} catch {
return null;
}
}
function scanAgentDirs(cwd: string): Map<string, AgentDef> {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
];
const agents = new Map<string, AgentDef>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const fullPath = resolve(dir, file);
const def = parseAgentFile(fullPath);
if (def && !agents.has(def.name.toLowerCase())) {
agents.set(def.name.toLowerCase(), def);
}
}
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
let allAgents: Map<string, AgentDef> = new Map();
let chains: ChainDef[] = [];
let activeChain: ChainDef | null = null;
let widgetCtx: any;
let sessionDir = "";
const agentSessions: Map<string, string | null> = new Map();
// Per-step state for the active chain
let stepStates: StepState[] = [];
let pendingReset = false;
function loadChains(cwd: string) {
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
allAgents = scanAgentDirs(cwd);
agentSessions.clear();
for (const [key] of allAgents) {
const sessionFile = join(sessionDir, `chain-${key}.json`);
agentSessions.set(key, existsSync(sessionFile) ? sessionFile : null);
}
const chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml");
if (existsSync(chainPath)) {
try {
chains = parseChainYaml(readFileSync(chainPath, "utf-8"));
} catch {
chains = [];
}
} else {
chains = [];
}
}
function activateChain(chain: ChainDef) {
activeChain = chain;
stepStates = chain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
// Skip widget re-registration if reset is pending — let before_agent_start handle it
if (!pendingReset) {
updateWidget();
}
}
// ── Card Rendering ──────────────────────────
function renderCard(state: StepState, colWidth: number, theme: any): string[] {
const w = colWidth - 2;
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
const statusColor = state.status === "pending" ? "dim"
: state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "pending" ? "○"
: state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const name = displayName(state.agent);
const nameStr = theme.fg("accent", theme.bold(truncate(name, w)));
const nameVisible = Math.min(name.length, w);
const statusStr = `${statusIcon} ${state.status}`;
const timeStr = state.status !== "pending" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
const statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length;
const workRaw = state.lastWork || "";
const workText = workRaw ? truncate(workRaw, Math.min(50, w - 1)) : "";
const workLine = workText ? theme.fg("muted", workText) : theme.fg("dim", "—");
const workVisible = workText ? workText.length : 1;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
const border = (content: string, visLen: number) =>
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│");
return [
theme.fg("dim", top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + workLine, 1 + workVisible),
theme.fg("dim", bot),
];
}
function updateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-chain", (_tui: any, theme: any) => {
const text = new Text("", 0, 1);
return {
render(width: number): string[] {
if (!activeChain || stepStates.length === 0) {
text.setText(theme.fg("dim", "No chain active. Use /chain to select one."));
return text.render(width);
}
const arrowWidth = 5; // " ──▶ "
const cols = stepStates.length;
const totalArrowWidth = arrowWidth * (cols - 1);
const colWidth = Math.max(12, Math.floor((width - totalArrowWidth) / cols));
const arrowRow = 2; // middle of 5-line card (0-indexed)
const cards = stepStates.map(s => renderCard(s, colWidth, theme));
const cardHeight = cards[0].length;
const outputLines: string[] = [];
for (let line = 0; line < cardHeight; line++) {
let row = cards[0][line];
for (let c = 1; c < cols; c++) {
if (line === arrowRow) {
row += theme.fg("dim", " ──▶ ");
} else {
row += " ".repeat(arrowWidth);
}
row += cards[c][line];
}
outputLines.push(row);
}
text.setText(outputLines.join("\n"));
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
// ── Run Agent (subprocess) ──────────────────
function runAgent(
agentDef: AgentDef,
task: string,
stepIndex: number,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`);
const hasSession = agentSessions.get(agentKey);
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", agentDef.tools,
"--thinking", "off",
"--append-system-prompt", agentDef.systemPrompt,
"--session", agentSessionFile,
];
if (hasSession) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
const startTime = Date.now();
const state = stepStates[stepIndex];
return new Promise((resolve) => {
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
const timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidget();
}, 1000);
let buffer = "";
proc.stdout!.setEncoding("utf-8");
proc.stdout!.on("data", (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
textChunks.push(delta.delta || "");
const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastWork = last;
updateWidget();
}
}
} catch {}
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", () => {});
proc.on("close", (code) => {
if (buffer.trim()) {
try {
const event = JSON.parse(buffer);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
}
} catch {}
}
clearInterval(timer);
const elapsed = Date.now() - startTime;
state.elapsed = elapsed;
const output = textChunks.join("");
state.lastWork = output.split("\n").filter((l: string) => l.trim()).pop() || "";
if (code === 0) {
agentSessions.set(agentKey, agentSessionFile);
}
resolve({ output, exitCode: code ?? 1, elapsed });
});
proc.on("error", (err) => {
clearInterval(timer);
resolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── Run Chain (sequential pipeline) ─────────
async function runChain(
task: string,
ctx: any,
): Promise<{ output: string; success: boolean; elapsed: number }> {
if (!activeChain) {
return { output: "No chain active", success: false, elapsed: 0 };
}
const chainStart = Date.now();
// Reset all steps to pending
stepStates = activeChain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
updateWidget();
let input = task;
const originalPrompt = task;
for (let i = 0; i < activeChain.steps.length; i++) {
const step = activeChain.steps[i];
stepStates[i].status = "running";
updateWidget();
const resolvedPrompt = step.prompt
.replace(/\$INPUT/g, input)
.replace(/\$ORIGINAL/g, originalPrompt);
const agentDef = allAgents.get(step.agent.toLowerCase());
if (!agentDef) {
stepStates[i].status = "error";
stepStates[i].lastWork = `Agent "${step.agent}" not found`;
updateWidget();
return {
output: `Error at step ${i + 1}: Agent "${step.agent}" not found. Available: ${Array.from(allAgents.keys()).join(", ")}`,
success: false,
elapsed: Date.now() - chainStart,
};
}
const result = await runAgent(agentDef, resolvedPrompt, i, ctx);
if (result.exitCode !== 0) {
stepStates[i].status = "error";
updateWidget();
return {
output: `Error at step ${i + 1} (${step.agent}): ${result.output}`,
success: false,
elapsed: Date.now() - chainStart,
};
}
stepStates[i].status = "done";
updateWidget();
input = result.output;
}
return { output: input, success: true, elapsed: Date.now() - chainStart };
}
// ── run_chain Tool ──────────────────────────
pi.registerTool({
name: "run_chain",
label: "Run Chain",
description: "Execute the active agent chain pipeline. Each step runs sequentially — output from one step feeds into the next. Agents maintain session context across runs.",
parameters: Type.Object({
task: Type.String({ description: "The task/prompt for the chain to process" }),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { task } = params as { task: string };
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Starting chain: ${activeChain?.name}...` }],
details: { chain: activeChain?.name, task, status: "running" },
});
}
const result = await runChain(task, ctx);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.success ? "done" : "error";
const summary = `[chain:${activeChain?.name}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
chain: activeChain?.name,
task,
status,
elapsed: result.elapsed,
fullOutput: result.output,
},
};
},
renderCall(args, theme) {
const task = (args as any).task || "";
const preview = task.length > 60 ? task.slice(0, 57) + "..." : task;
return new Text(
theme.fg("toolTitle", theme.bold("run_chain ")) +
theme.fg("accent", activeChain?.name || "?") +
theme.fg("dim", " — ") +
theme.fg("muted", preview),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "running") {
return new Text(
theme.fg("accent", `${details.chain || "chain"}`) +
theme.fg("dim", " running..."),
0, 0,
);
}
const icon = details.status === "done" ? "✓" : "✗";
const color = details.status === "done" ? "success" : "error";
const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0;
const header = theme.fg(color, `${icon} ${details.chain}`) +
theme.fg("dim", ` ${elapsed}s`);
if (options.expanded && details.fullOutput) {
const output = details.fullOutput.length > 4000
? details.fullOutput.slice(0, 4000) + "\n... [truncated]"
: details.fullOutput;
return new Text(header + "\n" + theme.fg("muted", output), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("chain", {
description: "Switch active chain",
handler: async (_args, ctx) => {
widgetCtx = ctx;
if (chains.length === 0) {
ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning");
return;
}
const options = chains.map(c => {
const steps = c.steps.map(s => displayName(s.agent)).join(" → ");
const desc = c.description ? `${c.description}` : "";
return `${c.name}${desc} (${steps})`;
});
const choice = await ctx.ui.select("Select Chain", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
activateChain(chains[idx]);
const flow = chains[idx].steps.map(s => displayName(s.agent)).join(" → ");
ctx.ui.setStatus("agent-chain", `Chain: ${chains[idx].name} (${chains[idx].steps.length} steps)`);
ctx.ui.notify(
`Chain: ${chains[idx].name}\n${chains[idx].description}\n${flow}`,
"info",
);
},
});
pi.registerCommand("chain-list", {
description: "List all available chains",
handler: async (_args, ctx) => {
widgetCtx = ctx;
if (chains.length === 0) {
ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning");
return;
}
const list = chains.map(c => {
const desc = c.description ? ` ${c.description}` : "";
const steps = c.steps.map((s, i) =>
` ${i + 1}. ${displayName(s.agent)}`
).join("\n");
return `${c.name}:${desc ? "\n" + desc : ""}\n${steps}`;
}).join("\n\n");
ctx.ui.notify(list, "info");
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
// Force widget reset on first turn after /new
if (pendingReset && activeChain) {
pendingReset = false;
widgetCtx = _ctx;
stepStates = activeChain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
updateWidget();
}
if (!activeChain) return {};
const flow = activeChain.steps.map(s => displayName(s.agent)).join(" → ");
const desc = activeChain.description ? `\n${activeChain.description}` : "";
// Build pipeline steps summary
const steps = activeChain.steps.map((s, i) => {
const agentDef = allAgents.get(s.agent.toLowerCase());
const agentDesc = agentDef?.description || "";
return `${i + 1}. **${displayName(s.agent)}** — ${agentDesc}`;
}).join("\n");
// Build full agent catalog (like agent-team.ts)
const seen = new Set<string>();
const agentCatalog = activeChain.steps
.filter(s => {
const key = s.agent.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(s => {
const agentDef = allAgents.get(s.agent.toLowerCase());
if (!agentDef) return `### ${displayName(s.agent)}\nAgent not found.`;
return `### ${displayName(agentDef.name)}\n${agentDef.description}\n**Tools:** ${agentDef.tools}\n**Role:** ${agentDef.systemPrompt}`;
})
.join("\n\n");
return {
systemPrompt: `You are an agent with a sequential pipeline called "${activeChain.name}" at your disposal.${desc}
You have full access to your own tools AND the run_chain tool to delegate to your team.
## Active Chain: ${activeChain.name}
Flow: ${flow}
${steps}
## Agent Details
${agentCatalog}
## When to Use run_chain
- Significant work: new features, refactors, multi-file changes, anything non-trivial
- Tasks that benefit from the full pipeline: planning, building, reviewing
- When you want structured, multi-agent collaboration on a problem
## When to Work Directly
- Simple one-off commands: reading a file, checking status, listing contents
- Quick lookups, small edits, answering questions about the codebase
- Anything you can handle in a single step without needing the pipeline
## How run_chain Works
- Pass a clear task description to run_chain
- Each step's output feeds into the next step as $INPUT
- Agents maintain session context — they remember previous work within this session
- You can run the chain multiple times with different tasks if needed
- After the chain completes, review the result and summarize for the user
## Guidelines
- Use your judgment — if it's quick, just do it; if it's real work, run the chain
- Keep chain tasks focused and clearly described
- You can mix direct work and chain runs in the same conversation`,
};
});
// ── Session Start ───────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
// Clear widget with both old and new ctx — one of them will be valid
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-chain", undefined);
}
_ctx.ui.setWidget("agent-chain", undefined);
widgetCtx = _ctx;
// Reset execution state — widget re-registration deferred to before_agent_start
stepStates = [];
activeChain = null;
pendingReset = true;
// Wipe chain session files — reset agent context on /new and launch
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.startsWith("chain-") && f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
// Reload chains + clear agentSessions map (all agents start fresh)
loadChains(_ctx.cwd);
if (chains.length === 0) {
_ctx.ui.notify("No chains found in .pi/agents/agent-chain.yaml", "warning");
return;
}
// Default to first chain — use /chain to switch
activateChain(chains[0]);
// run_chain is registered as a tool — available alongside all default tools
const flow = activeChain!.steps.map(s => displayName(s.agent)).join(" → ");
_ctx.ui.setStatus("agent-chain", `Chain: ${activeChain!.name} (${activeChain!.steps.length} steps)`);
_ctx.ui.notify(
`Chain: ${activeChain!.name}\n${activeChain!.description}\n${flow}\n\n` +
`/chain Switch chain\n` +
`/chain-list List all chains`,
"info",
);
// Footer: model | chain name | context bar
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(width: number): string[] {
const model = _ctx.model?.id || "no-model";
const usage = _ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const chainLabel = activeChain
? theme.fg("accent", activeChain.name)
: theme.fg("dim", "no chain");
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
chainLabel;
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}

View File

@@ -1,971 +0,0 @@
/**
* Agent Dashboard — Unified observability across all agent interfaces
*
* Passively tracks agent activity from team dispatches, subagent spawns,
* and chain pipeline runs. Provides a compact always-visible widget plus
* a full-screen overlay with four switchable views.
*
* Hooks into: dispatch_agent, subagent_create, subagent_continue, run_chain
* tool calls and their completions. Completely passive — never blocks.
*
* Commands:
* /dashboard — toggle full-screen overlay
* /dashboard clear — reset all tracked state
*
* Usage: pi -e extensions/agent-dashboard.ts
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Data Types ─────────────────────────────────────────────────────────
type AgentInterface = "team" | "subagent" | "chain";
interface TrackedAgent {
id: string;
name: string;
iface: AgentInterface;
status: "running" | "done" | "error";
task: string;
startedAt: number;
endedAt?: number;
elapsed: number;
toolCount: number;
lastText: string;
turnCount: number;
chainStep?: number;
chainName?: string;
teamName?: string;
}
interface AgentRun {
id: string;
name: string;
iface: AgentInterface;
task: string;
status: "done" | "error";
startedAt: number;
endedAt: number;
duration: number;
toolCount: number;
resultPreview: string;
chainStep?: number;
chainName?: string;
teamName?: string;
}
interface DashboardStats {
totalRuns: number;
totalSuccess: number;
totalError: number;
totalDuration: number;
agentRunCounts: Record<string, number>;
ifaceCounts: Record<AgentInterface, number>;
}
// ── Helpers ────────────────────────────────────────────────────────────
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
if (mins < 60) return `${mins}m ${remSecs}s`;
const hrs = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hrs}h ${remMins}m`;
}
function shortId(): string {
return Math.random().toString(36).slice(2, 6);
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
function emptyStats(): DashboardStats {
return {
totalRuns: 0,
totalSuccess: 0,
totalError: 0,
totalDuration: 0,
agentRunCounts: {},
ifaceCounts: { team: 0, subagent: 0, chain: 0 },
};
}
// ── Extension ──────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
// ── State ──────────────────────────────────────────────────────────
const activeAgents: Map<string, TrackedAgent> = new Map();
let history: AgentRun[] = [];
let stats: DashboardStats = emptyStats();
let widgetCtx: ExtensionContext | null = null;
let tickTimer: ReturnType<typeof setInterval> | null = null;
// Mapping from toolCallId → tracked agent info (with timestamp for staleness)
const pendingCalls: Map<string, { agentId: string; ts: number }> = new Map();
// Staleness threshold: 10 minutes
const STALE_TIMEOUT_MS = 10 * 60 * 1000;
// Inactivity auto-stop: stop tick after 30s with no active agents
let lastActivityTs = Date.now();
// ── Tracked tool names ─────────────────────────────────────────────
const TRACKED_TOOLS = new Set([
"dispatch_agent",
"subagent_create",
"subagent_continue",
"run_chain",
]);
// ── State Management ───────────────────────────────────────────────
function clearState() {
activeAgents.clear();
history = [];
stats = emptyStats();
pendingCalls.clear();
lastActivityTs = Date.now();
}
function addToHistory(agent: TrackedAgent) {
const run: AgentRun = {
id: agent.id,
name: agent.name,
iface: agent.iface,
task: agent.task,
status: agent.status === "error" ? "error" : "done",
startedAt: agent.startedAt,
endedAt: agent.endedAt || Date.now(),
duration: agent.elapsed,
toolCount: agent.toolCount,
resultPreview: truncate(agent.lastText, 200),
chainStep: agent.chainStep,
chainName: agent.chainName,
teamName: agent.teamName,
};
history.push(run);
// Ring buffer capped at 200
if (history.length > 200) {
history = history.slice(-200);
}
// Update stats
stats.totalRuns++;
if (run.status === "done") stats.totalSuccess++;
else stats.totalError++;
stats.totalDuration += run.duration;
stats.agentRunCounts[run.name] = (stats.agentRunCounts[run.name] || 0) + 1;
stats.ifaceCounts[run.iface] = (stats.ifaceCounts[run.iface] || 0) + 1;
}
// ── Tick Timer ─────────────────────────────────────────────────────
function startTick() {
if (tickTimer) return;
tickTimer = setInterval(() => {
const now = Date.now();
// Update elapsed on running agents
for (const agent of activeAgents.values()) {
if (agent.status === "running") {
agent.elapsed = now - agent.startedAt;
}
}
// Staleness check: expire pending calls older than 10 minutes
for (const [callId, pending] of pendingCalls) {
if (now - pending.ts > STALE_TIMEOUT_MS) {
pendingCalls.delete(callId);
const agent = activeAgents.get(pending.agentId);
if (agent && agent.status === "running") {
agent.status = "error";
agent.endedAt = now;
agent.elapsed = now - agent.startedAt;
agent.lastText = "Timed out (no completion after 10m)";
addToHistory(agent);
activeAgents.delete(pending.agentId);
}
}
}
// Auto-stop tick after 30s of inactivity (no active agents, no pending calls)
if (activeAgents.size === 0 && pendingCalls.size === 0) {
if (now - lastActivityTs > 30_000) {
stopTick();
return;
}
} else {
lastActivityTs = now;
}
updateWidget();
}, 1000);
}
function stopTick() {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
}
// ── Widget Rendering ───────────────────────────────────────────────
function updateWidget() {
if (!widgetCtx) return;
try {
widgetCtx.ui.setWidget("agent-dashboard", (_tui, theme) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("accent", s);
container.addChild(new DynamicBorder(borderFn));
const headerText = new Text("", 1, 0);
container.addChild(headerText);
const agentLines: Text[] = [];
// Pre-allocate up to 4 lines for active agents
for (let i = 0; i < 4; i++) {
const t = new Text("", 1, 0);
agentLines.push(t);
container.addChild(t);
}
const hintText = new Text("", 1, 0);
container.addChild(hintText);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const activeCount = activeAgents.size;
const doneCount = stats.totalSuccess;
const errorCount = stats.totalError;
// Line 1: summary bar
const line1 =
theme.fg("accent", " 📊 Dashboard") +
theme.fg("dim", " │ Active: ") + theme.fg(activeCount > 0 ? "accent" : "muted", `${activeCount}`) +
theme.fg("dim", " │ Done: ") + theme.fg("success", `${doneCount}`) +
theme.fg("dim", " │ Errors: ") + theme.fg(errorCount > 0 ? "error" : "muted", `${errorCount}`);
headerText.setText(truncateToWidth(line1, width - 4));
// Active agent lines
const agents = Array.from(activeAgents.values());
for (let i = 0; i < agentLines.length; i++) {
if (i < agents.length) {
const a = agents[i];
const icon = a.status === "running" ? "⟳"
: a.status === "done" ? "✓" : "✗";
const statusColor = a.status === "running" ? "accent"
: a.status === "done" ? "success" : "error";
const ifaceTag = theme.fg("dim", `[${a.iface}]`);
const elapsed = theme.fg("muted", fmtDuration(a.elapsed));
const tools = theme.fg("dim", `🔧${a.toolCount}`);
const lastText = a.lastText
? theme.fg("muted", truncate(a.lastText, Math.max(20, width - 60)))
: "";
const line =
" " + theme.fg(statusColor, icon) + " " +
theme.fg("accent", truncate(a.name, 16)) + " " +
ifaceTag + " " +
elapsed + " " +
tools +
(lastText ? theme.fg("dim", " │ ") + lastText : "");
agentLines[i].setText(truncateToWidth(line, width - 4));
} else {
agentLines[i].setText("");
}
}
// Hint line
const hintLine =
theme.fg("dim", " /dashboard") + theme.fg("muted", " — full view") +
theme.fg("dim", " │ ") +
theme.fg("muted", `${stats.totalRuns} total runs`) +
(stats.totalDuration > 0
? theme.fg("dim", " │ avg ") + theme.fg("muted", fmtDuration(Math.round(stats.totalDuration / Math.max(1, stats.totalRuns))))
: "");
hintText.setText(truncateToWidth(hintLine, width - 4));
return container.render(width);
},
invalidate() {
container.invalidate();
},
};
});
} catch {}
}
// ── Overlay ────────────────────────────────────────────────────────
async function openOverlay(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
let currentView = 0; // 0=Live, 1=History, 2=Interfaces, 3=Stats
let scrollOffset = 0;
const viewNames = ["1:Live", "2:History", "3:Interfaces", "4:Stats"];
await ctx.ui.custom((_tui, theme, _kb, done) => {
return {
render(width: number): string[] {
const lines: string[] = [];
// ── Header ──
lines.push("");
const tabs = viewNames.map((name, i) =>
i === currentView
? theme.fg("accent", theme.bold(`[${name}]`))
: theme.fg("dim", `[${name}]`)
).join(" ");
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("📊 Agent Dashboard")) +
" ".repeat(Math.max(1, width - 20 - viewNames.join(" ").length - 2)) +
tabs,
width,
));
lines.push(theme.fg("dim", "─".repeat(width)));
// ── View content ──
const contentLines = renderView(currentView, width, theme, scrollOffset);
lines.push(...contentLines);
// ── Footer controls ──
lines.push("");
lines.push(theme.fg("dim", "─".repeat(width)));
lines.push(truncateToWidth(
" " + theme.fg("dim", "1-4/Tab: views │ j/k: scroll │ c: clear │ q/Esc: close"),
width,
));
lines.push("");
return lines;
},
handleInput(data: string) {
if (matchesKey(data, "escape") || data === "q") {
done(undefined);
return;
}
if (data === "1") { currentView = 0; scrollOffset = 0; }
else if (data === "2") { currentView = 1; scrollOffset = 0; }
else if (data === "3") { currentView = 2; scrollOffset = 0; }
else if (data === "4") { currentView = 3; scrollOffset = 0; }
else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; }
else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); }
else if (matchesKey(data, "down") || data === "j") { scrollOffset++; }
else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); }
else if (matchesKey(data, "pageDown")) { scrollOffset += 20; }
else if (data === "c") {
clearState();
scrollOffset = 0;
}
_tui.requestRender();
},
invalidate() {},
};
}, {
overlay: true,
overlayOptions: { width: "90%", anchor: "center" },
});
}
// ── View Renderers ─────────────────────────────────────────────────
function renderView(view: number, width: number, theme: any, offset: number): string[] {
switch (view) {
case 0: return renderLiveView(width, theme, offset);
case 1: return renderHistoryView(width, theme, offset);
case 2: return renderInterfacesView(width, theme, offset);
case 3: return renderStatsView(width, theme, offset);
default: return [];
}
}
// ── View 1: Live ───────────────────────────────────────────────────
function renderLiveView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
const agents = Array.from(activeAgents.values());
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Active Agents")) +
theme.fg("dim", ` (${agents.length} running)`),
width,
));
lines.push("");
if (agents.length === 0) {
lines.push(truncateToWidth(
" " + theme.fg("dim", "No agents currently running. Activity will appear here when"),
width,
));
lines.push(truncateToWidth(
" " + theme.fg("dim", "dispatch_agent, subagent_create, subagent_continue, or run_chain is called."),
width,
));
lines.push("");
// Show recent completions as context
if (history.length > 0) {
lines.push(truncateToWidth(
" " + theme.fg("muted", `Last completed: ${history.length} agents`),
width,
));
const recent = history.slice(-3).reverse();
for (const run of recent) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
lines.push(truncateToWidth(
" " + theme.fg(color, `${icon} ${run.name}`) +
theme.fg("dim", ` [${run.iface}] `) +
theme.fg("muted", fmtDuration(run.duration)) +
theme.fg("dim", " — ") +
theme.fg("muted", truncate(run.task, 50)),
width,
));
}
}
return lines;
}
const allLines: string[] = [];
for (const agent of agents) {
const icon = agent.status === "running" ? "●"
: agent.status === "done" ? "✓" : "✗";
const statusColor = agent.status === "running" ? "accent"
: agent.status === "done" ? "success" : "error";
// Card top
allLines.push(truncateToWidth(
" " + theme.fg("dim", "┌─ ") +
theme.fg(statusColor, `${icon} ${agent.name}`) +
theme.fg("dim", ` [${agent.iface}]`) +
(agent.chainName ? theme.fg("dim", ` chain:${agent.chainName}`) : "") +
(agent.teamName ? theme.fg("dim", ` team:${agent.teamName}`) : "") +
(agent.chainStep !== undefined ? theme.fg("dim", ` step:${agent.chainStep}`) : "") +
theme.fg("dim", " ─".repeat(Math.max(0, Math.floor((width - 50) / 2)))),
width,
));
// Task
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", "Task: ") +
theme.fg("accent", truncate(agent.task, width - 20)),
width,
));
// Metrics
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", "Elapsed: ") + theme.fg("success", fmtDuration(agent.elapsed)) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Tools: ") + theme.fg("accent", `${agent.toolCount}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Turns: ") + theme.fg("accent", `${agent.turnCount}`),
width,
));
// Streaming text
if (agent.lastText) {
allLines.push(truncateToWidth(
" " + theme.fg("dim", "│ ") +
theme.fg("muted", truncate(agent.lastText, width - 10)),
width,
));
}
// Card bottom
allLines.push(truncateToWidth(
" " + theme.fg("dim", "└" + "─".repeat(Math.max(0, width - 5))),
width,
));
allLines.push("");
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── View 2: History ────────────────────────────────────────────────
function renderHistoryView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Completed Runs")) +
theme.fg("dim", ` (${history.length} total)`),
width,
));
lines.push("");
if (history.length === 0) {
lines.push(truncateToWidth(" " + theme.fg("dim", "No completed runs yet."), width));
return lines;
}
// Table header
const hdr =
theme.fg("accent", " Status") +
theme.fg("accent", " │ Name ") +
theme.fg("accent", " │ Interface ") +
theme.fg("accent", " │ Duration ") +
theme.fg("accent", " │ Tools ") +
theme.fg("accent", " │ Task");
lines.push(truncateToWidth(hdr, width));
lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(80, width - 4))), width));
// Show newest first
const rows: string[] = [];
const reversed = [...history].reverse();
for (const run of reversed) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
const ifaceLabel = run.iface.padEnd(9);
const nameLabel = truncate(run.name, 14).padEnd(14);
const durLabel = fmtDuration(run.duration).padEnd(8);
const toolLabel = String(run.toolCount).padStart(5);
const taskPreview = truncate(run.task, Math.max(10, width - 70));
const row =
" " + theme.fg(color, ` ${icon} `) +
theme.fg("dim", " │ ") + theme.fg("accent", nameLabel) +
theme.fg("dim", " │ ") + theme.fg("muted", ifaceLabel) +
theme.fg("dim", " │ ") + theme.fg("success", durLabel) +
theme.fg("dim", " │ ") + theme.fg("accent", toolLabel) +
theme.fg("dim", " │ ") + theme.fg("muted", taskPreview);
rows.push(row);
}
const visible = rows.slice(offset);
for (const row of visible) {
lines.push(truncateToWidth(row, width));
}
return lines;
}
// ── View 3: Interfaces ─────────────────────────────────────────────
function renderInterfacesView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Agents by Interface")),
width,
));
lines.push("");
const ifaceLabels: Record<AgentInterface, string> = {
team: "🏢 Team (dispatch_agent)",
subagent: "🤖 Subagent (subagent_create/continue)",
chain: "🔗 Chain (run_chain)",
};
const allLines: string[] = [];
for (const iface of ["team", "subagent", "chain"] as AgentInterface[]) {
const activeForIface = Array.from(activeAgents.values()).filter(a => a.iface === iface);
const historyForIface = history.filter(r => r.iface === iface);
const totalCount = stats.ifaceCounts[iface] || 0;
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold(ifaceLabels[iface])) +
theme.fg("dim", `${activeForIface.length} active, ${totalCount} completed`),
width,
));
allLines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(60, width - 6))), width));
// Active
if (activeForIface.length > 0) {
for (const agent of activeForIface) {
allLines.push(truncateToWidth(
" " + theme.fg("accent", "● ") +
theme.fg("accent", agent.name) +
theme.fg("dim", " — ") +
theme.fg("success", fmtDuration(agent.elapsed)) +
theme.fg("dim", " │ 🔧") + theme.fg("muted", `${agent.toolCount}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", truncate(agent.task, 40)),
width,
));
}
}
// Recent completed (last 5)
const recent = historyForIface.slice(-5).reverse();
if (recent.length > 0) {
for (const run of recent) {
const icon = run.status === "done" ? "✓" : "✗";
const color = run.status === "done" ? "success" : "error";
allLines.push(truncateToWidth(
" " + theme.fg(color, `${icon} `) +
theme.fg("muted", run.name) +
theme.fg("dim", " — ") +
theme.fg("muted", fmtDuration(run.duration)) +
theme.fg("dim", " │ ") +
theme.fg("muted", truncate(run.task, 40)),
width,
));
}
}
if (activeForIface.length === 0 && recent.length === 0) {
allLines.push(truncateToWidth(" " + theme.fg("dim", "No activity recorded."), width));
}
allLines.push("");
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── View 4: Stats ──────────────────────────────────────────────────
function renderStatsView(width: number, theme: any, offset: number): string[] {
const lines: string[] = [];
lines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Aggregate Statistics")),
width,
));
lines.push("");
const avgDur = stats.totalRuns > 0
? fmtDuration(Math.round(stats.totalDuration / stats.totalRuns))
: "—";
const successRate = stats.totalRuns > 0
? `${Math.round((stats.totalSuccess / stats.totalRuns) * 100)}%`
: "—";
const allLines: string[] = [];
// Summary cards
allLines.push(truncateToWidth(
" " +
theme.fg("muted", "Total Runs: ") + theme.fg("accent", `${stats.totalRuns}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Success: ") + theme.fg("success", `${stats.totalSuccess}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Errors: ") + theme.fg(stats.totalError > 0 ? "error" : "muted", `${stats.totalError}`) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Success Rate: ") + theme.fg("success", successRate),
width,
));
allLines.push(truncateToWidth(
" " +
theme.fg("muted", "Total Duration: ") + theme.fg("success", fmtDuration(stats.totalDuration)) +
theme.fg("dim", " │ ") +
theme.fg("muted", "Avg Duration: ") + theme.fg("accent", avgDur),
width,
));
allLines.push("");
// Interface breakdown
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Interface Breakdown")),
width,
));
allLines.push("");
const ifaceTotal = Math.max(1, stats.ifaceCounts.team + stats.ifaceCounts.subagent + stats.ifaceCounts.chain);
const barWidth = Math.min(30, Math.floor(width * 0.3));
for (const [iface, label] of [["team", "Team "], ["subagent", "Subagent "], ["chain", "Chain "]] as [AgentInterface, string][]) {
const count = stats.ifaceCounts[iface] || 0;
const ratio = count / ifaceTotal;
const filled = Math.round(ratio * barWidth);
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", label) + " " +
theme.fg("success", bar) + " " +
theme.fg("muted", `${count}`) +
theme.fg("dim", ` (${Math.round(ratio * 100)}%)`),
width,
));
}
allLines.push("");
// Most-used agents bar chart
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Most-Used Agents")),
width,
));
allLines.push("");
const agentEntries = Object.entries(stats.agentRunCounts).sort((a, b) => b[1] - a[1]);
if (agentEntries.length === 0) {
allLines.push(truncateToWidth(" " + theme.fg("dim", "No agent runs recorded yet."), width));
} else {
const maxCount = agentEntries[0][1];
for (const [name, count] of agentEntries.slice(0, 15)) {
const ratio = maxCount > 0 ? count / maxCount : 0;
const filled = Math.round(ratio * barWidth);
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", name.padEnd(16)) + " " +
theme.fg("success", bar) + " " +
theme.fg("muted", `${count}`),
width,
));
}
}
allLines.push("");
// Per-agent average durations
if (history.length > 0) {
allLines.push(truncateToWidth(
" " + theme.fg("accent", theme.bold("Average Duration by Agent")),
width,
));
allLines.push("");
const durByAgent: Record<string, number[]> = {};
for (const run of history) {
if (!durByAgent[run.name]) durByAgent[run.name] = [];
durByAgent[run.name].push(run.duration);
}
const durEntries = Object.entries(durByAgent).sort((a, b) => {
const avgA = a[1].reduce((s, v) => s + v, 0) / a[1].length;
const avgB = b[1].reduce((s, v) => s + v, 0) / b[1].length;
return avgB - avgA;
});
for (const [name, durations] of durEntries) {
const avg = durations.reduce((s, v) => s + v, 0) / durations.length;
const min = Math.min(...durations);
const max = Math.max(...durations);
allLines.push(truncateToWidth(
" " +
theme.fg("accent", name.padEnd(16)) +
theme.fg("dim", " avg: ") + theme.fg("success", fmtDuration(Math.round(avg)).padEnd(8)) +
theme.fg("dim", " min: ") + theme.fg("muted", fmtDuration(min).padEnd(8)) +
theme.fg("dim", " max: ") + theme.fg("muted", fmtDuration(max).padEnd(8)) +
theme.fg("dim", " runs: ") + theme.fg("muted", `${durations.length}`),
width,
));
}
}
const visible = allLines.slice(offset);
lines.push(...visible);
return lines;
}
// ── Commands ───────────────────────────────────────────────────────
pi.registerCommand("dashboard", {
description: "Open Agent Dashboard overlay. Args: clear",
handler: async (args, ctx) => {
widgetCtx = ctx;
const arg = (args || "").trim().toLowerCase();
if (arg === "clear") {
stopTick();
clearState();
startTick();
ctx.ui.notify("📊 Dashboard: All data cleared.", "info");
updateWidget();
return;
}
await openOverlay(ctx);
},
});
// ── Event Handlers ─────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
stopTick();
widgetCtx = ctx;
clearState();
startTick();
updateWidget();
});
pi.on("before_agent_start", async (_event, ctx) => {
widgetCtx = ctx;
return undefined;
});
pi.on("agent_end", async (_event, ctx) => {
widgetCtx = ctx;
updateWidget();
});
pi.on("tool_call", async (event, _ctx) => {
try {
const toolName = event.toolName;
if (!TRACKED_TOOLS.has(toolName)) return undefined;
const input = event.input;
const now = Date.now();
const callId = event.toolCallId;
lastActivityTs = now;
if (toolName === "dispatch_agent") {
const agentName = (input.agent as string) || "unknown";
const task = (input.task as string) || "";
const id = `team:${agentName}:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: agentName,
iface: "team",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
teamName: agentName,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "subagent_create") {
const task = (input.task as string) || "";
const id = `sub:create:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: "Subagent",
iface: "subagent",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "subagent_continue") {
// Always create a new tracking entry using the widget's ID from input
const subId = input.id;
const prompt = (input.prompt as string) || "";
const id = `sub:cont:${subId}:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: `Subagent #${subId}`,
iface: "subagent",
status: "running",
task: prompt,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
} else if (toolName === "run_chain") {
const task = (input.task as string) || "";
const id = `chain:${shortId()}`;
const tracked: TrackedAgent = {
id,
name: "chain",
iface: "chain",
status: "running",
task,
startedAt: now,
elapsed: 0,
toolCount: 0,
lastText: "",
turnCount: 1,
chainName: "pipeline",
};
activeAgents.set(id, tracked);
pendingCalls.set(callId, { agentId: id, ts: now });
}
// Ensure tick is running when we have active agents
startTick();
updateWidget();
} catch {}
return undefined;
});
pi.on("tool_execution_end", async (event) => {
try {
const toolName = event.toolName;
if (!TRACKED_TOOLS.has(toolName)) return;
const now = Date.now();
const callId = event.toolCallId;
lastActivityTs = now;
const pending = pendingCalls.get(callId);
if (pending) {
pendingCalls.delete(callId);
const agent = activeAgents.get(pending.agentId);
if (agent) {
agent.status = event.isError ? "error" : "done";
agent.endedAt = now;
agent.elapsed = now - agent.startedAt;
// Extract result preview if available
try {
const result = event.result;
if (result?.content) {
for (const block of result.content) {
if (block.type === "text" && block.text) {
agent.lastText = block.text.slice(0, 200);
break;
}
}
}
} catch {}
// Move to history
addToHistory(agent);
activeAgents.delete(pending.agentId);
}
}
updateWidget();
} catch {}
});
}

View File

@@ -1,944 +0,0 @@
/**
* Agent Team — Dispatcher-only orchestrator with grid dashboard
*
* The primary Pi agent has NO codebase tools. It can ONLY delegate work
* to specialist agents via the `dispatch_agent` tool (single) or
* `dispatch_agents` tool (parallel batch). Each specialist maintains
* its own Pi session for cross-invocation memory.
*
* Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md.
* Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets
* you pick which team to work with. Only team members are available for dispatch.
*
* Commands:
* /agents-team — switch active team
* /agents-list — list loaded agents
* /agents-grid N — set column count (default 2)
*
* Usage: pi -e extensions/agent-team.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn, type ChildProcess } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Constants ────────────────────────────────────
const AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per dispatch
const WIDGET_THROTTLE_MS = 500; // max widget refresh rate
// ── Types ────────────────────────────────────────
interface AgentDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
file: string;
}
interface AgentState {
def: AgentDef;
status: "idle" | "running" | "done" | "error";
task: string;
toolCount: number;
elapsed: number;
lastWork: string;
contextPct: number;
sessionFile: string | null;
runCount: number;
timer?: ReturnType<typeof setInterval>;
proc?: ChildProcess;
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// ── Teams YAML Parser ────────────────────────────
function parseTeamsYaml(raw: string): Record<string, string[]> {
const teams: Record<string, string[]> = {};
let current: string | null = null;
for (const line of raw.split("\n")) {
const teamMatch = line.match(/^(\S[^:]*):$/);
if (teamMatch) {
current = teamMatch[1].trim();
teams[current] = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && current) {
teams[current].push(itemMatch[1].trim());
}
}
return teams;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string): AgentDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
if (!frontmatter.name) return null;
return {
name: frontmatter.name,
description: frontmatter.description || "",
tools: frontmatter.tools || "read,grep,find,ls",
systemPrompt: match[2].trim(),
file: filePath,
};
} catch {
return null;
}
}
function scanAgentDirs(cwd: string): AgentDef[] {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
];
const agents: AgentDef[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const fullPath = resolve(dir, file);
const def = parseAgentFile(fullPath);
if (def && !seen.has(def.name.toLowerCase())) {
seen.add(def.name.toLowerCase());
agents.push(def);
}
}
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const agentStates: Map<string, AgentState> = new Map();
const activeProcesses: Set<ChildProcess> = new Set();
let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {};
let activeTeamName = "";
let gridCols = 2;
let widgetCtx: any;
let sessionDir = "";
let contextWindow = 0;
// ── Throttled Widget Update ──────────────────
let widgetDirty = false;
let widgetTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleWidgetUpdate() {
widgetDirty = true;
if (widgetTimer) return; // already scheduled
widgetTimer = setTimeout(() => {
widgetTimer = null;
if (widgetDirty) {
widgetDirty = false;
doUpdateWidget();
}
}, WIDGET_THROTTLE_MS);
}
function flushWidgetUpdate() {
if (widgetTimer) {
clearTimeout(widgetTimer);
widgetTimer = null;
}
widgetDirty = false;
doUpdateWidget();
}
function loadAgents(cwd: string) {
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
allAgentDefs = scanAgentDirs(cwd);
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (existsSync(teamsPath)) {
try {
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
} catch {
teams = {};
}
} else {
teams = {};
}
if (Object.keys(teams).length === 0) {
teams = { all: allAgentDefs.map(d => d.name) };
}
}
function activateTeam(teamName: string) {
activeTeamName = teamName;
const members = teams[teamName] || [];
const defsByName = new Map(allAgentDefs.map(d => [d.name.toLowerCase(), d]));
agentStates.clear();
for (const member of members) {
const def = defsByName.get(member.toLowerCase());
if (!def) continue;
const key = def.name.toLowerCase().replace(/\s+/g, "-");
const sessionFile = join(sessionDir, `${key}.json`);
agentStates.set(def.name.toLowerCase(), {
def,
status: "idle",
task: "",
toolCount: 0,
elapsed: 0,
lastWork: "",
contextPct: 0,
sessionFile: existsSync(sessionFile) ? sessionFile : null,
runCount: 0,
});
}
const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
}
// ── Kill all tracked child processes ─────────
function killAllAgents() {
for (const proc of activeProcesses) {
try { proc.kill("SIGTERM"); } catch {}
}
// Force kill after 3s
setTimeout(() => {
for (const proc of activeProcesses) {
try { proc.kill("SIGKILL"); } catch {}
}
}, 3000);
}
// ── Grid Rendering ───────────────────────────
function renderCard(state: AgentState, colWidth: number, theme: any): string[] {
const w = colWidth - 2;
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
const statusColor = state.status === "idle" ? "dim"
: state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "idle" ? "○"
: state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const name = displayName(state.def.name);
const nameStr = theme.fg("accent", theme.bold(truncate(name, w)));
const nameVisible = Math.min(name.length, w);
const statusStr = `${statusIcon} ${state.status}`;
const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
const statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length;
const filled = Math.ceil(state.contextPct / 20);
const bar = "#".repeat(filled) + "-".repeat(5 - filled);
const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`;
const ctxLine = theme.fg("dim", ctxStr);
const ctxVisible = ctxStr.length;
const workRaw = state.task
? (state.lastWork || state.task)
: state.def.description;
const workText = truncate(workRaw, Math.min(50, w - 1));
const workLine = theme.fg("muted", workText);
const workVisible = workText.length;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
const border = (content: string, visLen: number) =>
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│");
return [
theme.fg("dim", top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + ctxLine, 1 + ctxVisible),
border(" " + workLine, 1 + workVisible),
theme.fg("dim", bot),
];
}
function doUpdateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
const text = new Text("", 0, 1);
return {
render(width: number): string[] {
if (agentStates.size === 0) {
text.setText(theme.fg("dim", "No agents found. Add .md files to agents/"));
return text.render(width);
}
const cols = Math.min(gridCols, agentStates.size);
const gap = 1;
const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
const agents = Array.from(agentStates.values());
const rows: string[][] = [];
for (let i = 0; i < agents.length; i += cols) {
const rowAgents = agents.slice(i, i + cols);
const cards = rowAgents.map(a => renderCard(a, colWidth, theme));
while (cards.length < cols) {
cards.push(Array(6).fill(" ".repeat(colWidth)));
}
const cardHeight = cards[0].length;
for (let line = 0; line < cardHeight; line++) {
rows.push(cards.map(card => card[line] || ""));
}
}
const output = rows.map(cols => cols.join(" ".repeat(gap)));
text.setText(output.join("\n"));
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
// ── Dispatch Agent (returns Promise) ─────────
function dispatchAgent(
agentName: string,
task: string,
ctx: any,
signal?: AbortSignal,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const key = agentName.toLowerCase();
const state = agentStates.get(key);
if (!state) {
return Promise.resolve({
output: `Agent "${agentName}" not found. Available: ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`,
exitCode: 1,
elapsed: 0,
});
}
if (state.status === "running") {
return Promise.resolve({
output: `Agent "${displayName(state.def.name)}" is already running. Wait for it to finish.`,
exitCode: 1,
elapsed: 0,
});
}
// Reset state for new run
state.status = "running";
state.task = task;
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.contextPct = 0;
state.runCount++;
scheduleWidgetUpdate();
const startTime = Date.now();
state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
scheduleWidgetUpdate();
}, 1000);
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", state.def.tools,
"--thinking", "off",
"--append-system-prompt", state.def.systemPrompt,
"--session", agentSessionFile,
];
if (state.sessionFile) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
let resolved = false;
return new Promise((promiseResolve) => {
// Guard against double-resolve
const safeResolve = (val: { output: string; exitCode: number; elapsed: number }) => {
if (resolved) return;
resolved = true;
promiseResolve(val);
};
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
state.proc = proc;
activeProcesses.add(proc);
// ── Timeout guard ──
const timeout = setTimeout(() => {
try { proc.kill("SIGTERM"); } catch {}
// Force kill after 3s if still alive
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
}, AGENT_TIMEOUT_MS);
// ── AbortSignal support ──
const onAbort = () => {
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
let buffer = "";
proc.stdout!.setEncoding("utf-8");
proc.stdout!.on("data", (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
textChunks.push(delta.delta || "");
const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastWork = last;
scheduleWidgetUpdate();
}
} else if (event.type === "tool_execution_start") {
state.toolCount++;
scheduleWidgetUpdate();
} else if (event.type === "message_end") {
const msg = event.message;
if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
scheduleWidgetUpdate();
}
} else if (event.type === "agent_end") {
const msgs = event.messages || [];
const last = [...msgs].reverse().find((m: any) => m.role === "assistant");
if (last?.usage && contextWindow > 0) {
state.contextPct = ((last.usage.input || 0) / contextWindow) * 100;
scheduleWidgetUpdate();
}
}
} catch {}
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", () => {});
proc.on("close", (code) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
// Process any remaining buffer
if (buffer.trim()) {
try {
const event = JSON.parse(buffer);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
}
} catch {}
}
clearInterval(state.timer);
state.elapsed = Date.now() - startTime;
const timedOut = state.elapsed >= AGENT_TIMEOUT_MS;
state.status = timedOut ? "error" : (code === 0 ? "done" : "error");
if (code === 0) {
state.sessionFile = agentSessionFile;
}
const full = textChunks.join("");
state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || "";
flushWidgetUpdate();
const statusMsg = timedOut
? `${displayName(state.def.name)} timed out after ${Math.round(AGENT_TIMEOUT_MS / 1000)}s`
: `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`;
ctx.ui.notify(statusMsg, state.status === "done" ? "success" : "error");
const output = timedOut
? full + "\n\n[TIMED OUT after " + Math.round(AGENT_TIMEOUT_MS / 1000) + "s]"
: full;
safeResolve({
output,
exitCode: code ?? 1,
elapsed: state.elapsed,
});
});
proc.on("error", (err) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
clearInterval(state.timer);
state.status = "error";
state.lastWork = `Error: ${err.message}`;
flushWidgetUpdate();
safeResolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── dispatch_agent Tool (single) ─────────────
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch Agent",
description: "Dispatch a task to a single specialist agent. The agent executes the task and returns the result. For dispatching multiple agents in parallel, use dispatch_agents instead.",
parameters: Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent to execute" }),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { agent, task } = params as { agent: string; task: string };
try {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching to ${agent}...` }],
details: { agent, task, status: "dispatching" },
});
}
const result = await dispatchAgent(agent, task, ctx, signal);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.exitCode === 0 ? "done" : "error";
const summary = `[${agent}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
agent,
task,
status,
elapsed: result.elapsed,
exitCode: result.exitCode,
fullOutput: result.output,
},
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Error dispatching to ${agent}: ${err?.message || err}` }],
details: { agent, task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" },
};
}
},
renderCall(args, theme) {
const agentName = (args as any).agent || "?";
const task = (args as any).task || "";
const preview = task.length > 60 ? task.slice(0, 57) + "..." : task;
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agent ")) +
theme.fg("accent", agentName) +
theme.fg("dim", " — ") +
theme.fg("muted", preview),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `${details.agent || "?"}`) +
theme.fg("dim", " working..."),
0, 0,
);
}
const icon = details.status === "done" ? "✓" : "✗";
const color = details.status === "done" ? "success" : "error";
const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0;
const header = theme.fg(color, `${icon} ${details.agent}`) +
theme.fg("dim", ` ${elapsed}s`);
if (options.expanded && details.fullOutput) {
const output = details.fullOutput.length > 4000
? details.fullOutput.slice(0, 4000) + "\n... [truncated]"
: details.fullOutput;
return new Text(header + "\n" + theme.fg("muted", output), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── dispatch_agents Tool (parallel batch) ────
pi.registerTool({
name: "dispatch_agents",
label: "Dispatch Agents (Parallel)",
description: "Dispatch tasks to multiple specialist agents in parallel. All agents run simultaneously and results are returned together. Much faster than sequential dispatch_agent calls when tasks are independent.",
parameters: Type.Object({
dispatches: Type.Array(
Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent" }),
}),
{ description: "Array of {agent, task} pairs to dispatch in parallel", minItems: 1 },
),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { dispatches } = params as { dispatches: { agent: string; task: string }[] };
const agentNames = dispatches.map(d => d.agent).join(", ");
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching ${dispatches.length} agents in parallel: ${agentNames}` }],
details: { dispatches, status: "dispatching", count: dispatches.length },
});
}
// Launch all in parallel
const promises = dispatches.map(({ agent, task }) =>
dispatchAgent(agent, task, ctx, signal).then(result => ({
agent,
task,
...result,
}))
);
const results = await Promise.all(promises);
const summaryParts: string[] = [];
const allDetails: any[] = [];
for (const r of results) {
const status = r.exitCode === 0 ? "done" : "error";
const truncated = r.output.length > 4000
? r.output.slice(0, 4000) + "\n... [truncated]"
: r.output;
summaryParts.push(`## [${r.agent}] ${status} in ${Math.round(r.elapsed / 1000)}s\n\n${truncated}`);
allDetails.push({
agent: r.agent,
task: r.task,
status,
elapsed: r.elapsed,
exitCode: r.exitCode,
fullOutput: r.output,
});
}
const doneCount = results.filter(r => r.exitCode === 0).length;
const header = `Parallel dispatch complete: ${doneCount}/${results.length} succeeded`;
return {
content: [{ type: "text", text: `${header}\n\n${summaryParts.join("\n\n---\n\n")}` }],
details: {
dispatches: allDetails,
status: "complete",
count: results.length,
succeeded: doneCount,
},
};
},
renderCall(args, theme) {
const dispatches = (args as any).dispatches || [];
const names = dispatches.map((d: any) => d.agent || "?").join(", ");
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agents ")) +
theme.fg("accent", `[${dispatches.length}] `) +
theme.fg("muted", names),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `● Parallel dispatch`) +
theme.fg("dim", ` ${details.count || "?"} agents working...`),
0, 0,
);
}
const header = theme.fg("success", `${details.succeeded}`) +
theme.fg("dim", `/${details.count} agents completed`);
if (options.expanded && Array.isArray(details.dispatches)) {
const lines = details.dispatches.map((d: any) => {
const icon = d.status === "done" ? "✓" : "✗";
const color = d.status === "done" ? "success" : "error";
return theme.fg(color, ` ${icon} ${d.agent}`) +
theme.fg("dim", ` ${Math.round(d.elapsed / 1000)}s`);
});
return new Text(header + "\n" + lines.join("\n"), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("agents-team", {
description: "Select a team to work with",
handler: async (_args, ctx) => {
widgetCtx = ctx;
const teamNames = Object.keys(teams);
if (teamNames.length === 0) {
ctx.ui.notify("No teams defined in .pi/agents/teams.yaml", "warning");
return;
}
const options = teamNames.map(name => {
const members = teams[name].map(m => displayName(m));
return `${name}${members.join(", ")}`;
});
const choice = await ctx.ui.select("Select Team", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
const name = teamNames[idx];
activateTeam(name);
flushWidgetUpdate();
ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`);
ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info");
},
});
pi.registerCommand("agents-list", {
description: "List all loaded agents",
handler: async (_args, _ctx) => {
widgetCtx = _ctx;
const names = Array.from(agentStates.values())
.map(s => {
const session = s.sessionFile ? "resumed" : "new";
return `${displayName(s.def.name)} (${s.status}, ${session}, runs: ${s.runCount}): ${s.def.description}`;
})
.join("\n");
_ctx.ui.notify(names || "No agents loaded", "info");
},
});
pi.registerCommand("agents-grid", {
description: "Set grid columns: /agents-grid <1-6>",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const items = ["1", "2", "3", "4", "5", "6"].map(n => ({
value: n,
label: `${n} columns`,
}));
const filtered = items.filter(i => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : items;
},
handler: async (args, _ctx) => {
widgetCtx = _ctx;
const n = parseInt(args?.trim() || "", 10);
if (n >= 1 && n <= 6) {
gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
flushWidgetUpdate();
} else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
}
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
const agentCatalog = Array.from(agentStates.values())
.map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`)
.join("\n\n");
const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
return {
systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks.
You do NOT have direct access to the codebase. You MUST delegate all work through
agents using the dispatch_agent or dispatch_agents tools.
## Active Team: ${activeTeamName}
Members: ${teamMembers}
You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team.
## How to Work
- Analyze the user's request and break it into clear sub-tasks
- Choose the right agent(s) for each sub-task
- **Use dispatch_agents for independent parallel tasks** — this is much faster
- Use dispatch_agent for sequential tasks where order matters
- Review results and dispatch follow-up agents if needed
- If a task fails, try a different agent or adjust the task description
- Summarize the outcome for the user
## Rules
- NEVER try to read, write, or execute code directly — you have no such tools
- ALWAYS use dispatch_agent or dispatch_agents to get work done
- **Prefer dispatch_agents when tasks are independent** — parallelism saves time
- You can chain agents: use scout to explore, then builder to implement
- You can dispatch the same agent multiple times with different tasks
- Keep tasks focused — one clear objective per dispatch
- Each agent has a ${Math.round(AGENT_TIMEOUT_MS / 1000)}s timeout — break large tasks into smaller ones
## Agents
${agentCatalog}`,
};
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined);
}
widgetCtx = _ctx;
contextWindow = _ctx.model?.contextWindow || 0;
// Wipe old agent session files so subagents start fresh
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
loadAgents(_ctx.cwd);
const teamNames = Object.keys(teams);
if (teamNames.length > 0) {
activateTeam(teamNames[0]);
}
pi.setActiveTools(["dispatch_agent", "dispatch_agents"]);
_ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`);
const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
_ctx.ui.notify(
`Team: ${activeTeamName} (${members})\n` +
`Team sets loaded from: .pi/agents/teams.yaml\n\n` +
`/agents-team Select a team\n` +
`/agents-list List active agents and status\n` +
`/agents-grid <1-6> Set grid column count`,
"info",
);
flushWidgetUpdate();
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(width: number): string[] {
const model = _ctx.model?.id || "no-model";
const usage = _ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const running = Array.from(agentStates.values()).filter(s => s.status === "running").length;
const runningStr = running > 0 ? theme.fg("accent", `${running} running`) : "";
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
theme.fg("accent", activeTeamName) +
runningStr;
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
// ── Cleanup on exit ──────────────────────────
process.on("exit", () => killAllAgents());
process.on("SIGINT", () => { killAllAgents(); process.exit(0); });
process.on("SIGTERM", () => { killAllAgents(); process.exit(0); });
}

View File

@@ -1,265 +0,0 @@
/**
* Cross-Agent — Load commands, skills, and agents from other AI coding agents
*
* Scans .claude/, .gemini/, .codex/ directories (project + global) for:
* commands/*.md → registered as /name
* skills/ → listed as /skill:name (discovery only)
* agents/*.md → listed as @name (discovery only)
*
* Usage: pi -e extensions/cross-agent.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { applyExtensionDefaults } from "./themeMap.ts";
import { wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui";
// --- Synthwave palette ---
function bg(s: string): string {
return `\x1b[48;2;52;20;58m${s}\x1b[49m`;
}
function pink(s: string): string {
return `\x1b[38;2;255;126;219m${s}\x1b[39m`;
}
function cyan(s: string): string {
return `\x1b[38;2;54;249;246m${s}\x1b[39m`;
}
function green(s: string): string {
return `\x1b[38;2;114;241;184m${s}\x1b[39m`;
}
function yellow(s: string): string {
return `\x1b[38;2;254;222;93m${s}\x1b[39m`;
}
function dim(s: string): string {
return `\x1b[38;2;120;100;140m${s}\x1b[39m`;
}
function bold(s: string): string {
return `\x1b[1m${s}\x1b[22m`;
}
interface Discovered {
name: string;
description: string;
content: string;
}
interface SourceGroup {
source: string;
commands: Discovered[];
skills: string[];
agents: Discovered[];
}
function parseFrontmatter(raw: string): { description: string; body: string; fields: Record<string, string> } {
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) return { description: "", body: raw, fields: {} };
const front = match[1];
const body = match[2];
const fields: Record<string, string> = {};
for (const line of front.split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return { description: fields.description || "", body, fields };
}
function expandArgs(template: string, args: string): string {
const parts = args.split(/\s+/).filter(Boolean);
let result = template;
result = result.replace(/\$ARGUMENTS|\$@/g, args);
for (let i = 0; i < parts.length; i++) {
result = result.replaceAll(`$${i + 1}`, parts[i]);
}
return result;
}
function scanCommands(dir: string): Discovered[] {
if (!existsSync(dir)) return [];
const items: Discovered[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { description, body } = parseFrontmatter(raw);
items.push({
name: basename(file, ".md"),
description: description || body.split("\n").find((l) => l.trim())?.trim() || "",
content: body,
});
}
} catch {}
return items;
}
function scanSkills(dir: string): string[] {
if (!existsSync(dir)) return [];
const names: string[] = [];
try {
for (const entry of readdirSync(dir)) {
const skillFile = join(dir, entry, "SKILL.md");
const flatFile = join(dir, entry);
if (existsSync(skillFile) && statSync(skillFile).isFile()) {
names.push(entry);
} else if (entry.endsWith(".md") && statSync(flatFile).isFile()) {
names.push(basename(entry, ".md"));
}
}
} catch {}
return names;
}
function scanAgents(dir: string): Discovered[] {
if (!existsSync(dir)) return [];
const items: Discovered[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { fields } = parseFrontmatter(raw);
items.push({
name: fields.name || basename(file, ".md"),
description: fields.description || "",
content: raw,
});
}
} catch {}
return items;
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
const home = homedir();
const cwd = ctx.cwd;
const providers = ["claude", "gemini", "codex"];
const groups: SourceGroup[] = [];
for (const p of providers) {
for (const [dir, label] of [
[join(cwd, `.${p}`), `.${p}`],
[join(home, `.${p}`), `~/.${p}`],
] as const) {
const commands = scanCommands(join(dir, "commands"));
const skills = scanSkills(join(dir, "skills"));
const agents = scanAgents(join(dir, "agents"));
if (commands.length || skills.length || agents.length) {
groups.push({ source: label, commands, skills, agents });
}
}
}
// Also scan .pi/agents/ (pi-vs-cc pattern)
const localAgents = scanAgents(join(cwd, ".pi", "agents"));
if (localAgents.length) {
groups.push({ source: ".pi/agents", commands: [], skills: [], agents: localAgents });
}
// Register commands
const seenCmds = new Set<string>();
let totalCommands = 0;
let totalSkills = 0;
let totalAgents = 0;
for (const g of groups) {
totalSkills += g.skills.length;
totalAgents += g.agents.length;
for (const cmd of g.commands) {
if (seenCmds.has(cmd.name)) continue;
seenCmds.add(cmd.name);
totalCommands++;
pi.registerCommand(cmd.name, {
description: `[${g.source}] ${cmd.description}`.slice(0, 120),
handler: async (args) => {
pi.sendUserMessage(expandArgs(cmd.content, args || ""));
},
});
}
}
if (groups.length === 0) return;
// We delay slightly so it doesn't get instantly overwritten by system-select's default startup notify
setTimeout(() => {
if (!ctx.hasUI) return;
// Reduce max width slightly to ensure it never overflows and breaks the next line
const width = Math.min((process.stdout.columns || 80) - 4, 100);
const pad = bg(" ".repeat(width));
const lines: string[] = [];
lines.push(""); // space from prev
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
// Title with counts
const counts: string[] = [];
if (g.skills.length) counts.push(yellow("(") + green(`${g.skills.length}`) + dim(` skill${g.skills.length > 1 ? "s" : ""}`) + yellow(")"));
if (g.commands.length) counts.push(yellow("(") + green(`${g.commands.length}`) + dim(` command${g.commands.length > 1 ? "s" : ""}`) + yellow(")"));
if (g.agents.length) counts.push(yellow("(") + green(`${g.agents.length}`) + dim(` agent${g.agents.length > 1 ? "s" : ""}`) + yellow(")"));
const countStr = counts.length ? " " + counts.join(" ") : "";
lines.push(pink(bold(` ${g.source}`)) + countStr);
// Build body content
const items: string[] = [];
if (g.commands.length) {
items.push(
yellow("/") +
g.commands.map((c) => cyan(c.name)).join(yellow(", /"))
);
}
if (g.skills.length) {
items.push(
yellow("/skill:") +
g.skills.map((s) => cyan(s)).join(yellow(", /skill:"))
);
}
if (g.agents.length) {
items.push(
yellow("@") +
g.agents.map((a) => green(a.name)).join(yellow(", @"))
);
}
const body = items.join("\n");
// Top padding
lines.push(pad);
// Wrap body text, cap at 3 rows
const maxRows = 3;
const innerWidth = width - 4;
const wrapped = wrapTextWithAnsi(body, innerWidth);
const totalItems = g.commands.length + g.skills.length + g.agents.length;
const shown = wrapped.slice(0, maxRows);
for (const wline of shown) {
const vis = visibleWidth(wline);
const fill = Math.max(0, width - vis - 4);
lines.push(bg(" " + wline + " ".repeat(fill) + " "));
}
if (wrapped.length > maxRows) {
const overflow = dim(` ... ${totalItems - 15 > 0 ? totalItems - 15 : "more"} more`);
const oVis = visibleWidth(overflow);
const oFill = Math.max(0, width - oVis - 2);
lines.push(bg(overflow + " ".repeat(oFill) + " "));
}
// Bottom padding
lines.push(pad);
// Spacing between groups
if (i < groups.length - 1) lines.push("");
}
// We send it as "info" which forces it to be a raw text element in the chat
// without the widget container, but preserving all our ANSI colors!
ctx.ui.notify(lines.join("\n"), "info");
}, 100);
});
}

View File

@@ -1,206 +0,0 @@
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { parse as yamlParse } from "yaml";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { applyExtensionDefaults } from "./themeMap.ts";
interface Rule {
pattern: string;
reason: string;
ask?: boolean;
}
interface Rules {
bashToolPatterns: Rule[];
zeroAccessPaths: string[];
readOnlyPaths: string[];
noDeletePaths: string[];
}
export default function (pi: ExtensionAPI) {
let rules: Rules = {
bashToolPatterns: [],
zeroAccessPaths: [],
readOnlyPaths: [],
noDeletePaths: [],
};
function resolvePath(p: string, cwd: string): string {
if (p.startsWith("~")) {
p = path.join(os.homedir(), p.slice(1));
}
return path.resolve(cwd, p);
}
function isPathMatch(targetPath: string, pattern: string, cwd: string): boolean {
// Simple glob-to-regex or substring match
// Expand tilde in pattern if present
const resolvedPattern = pattern.startsWith("~") ? path.join(os.homedir(), pattern.slice(1)) : pattern;
// If pattern ends with /, it's a directory match
if (resolvedPattern.endsWith("/")) {
const absolutePattern = path.isAbsolute(resolvedPattern) ? resolvedPattern : path.resolve(cwd, resolvedPattern);
return targetPath.startsWith(absolutePattern);
}
// Handle basic wildcards *
const regexPattern = resolvedPattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars
.replace(/\*/g, ".*"); // convert * to .*
const regex = new RegExp(`^${regexPattern}$|^${regexPattern}/|/${regexPattern}$|/${regexPattern}/`);
// Match against absolute path and relative-to-cwd path
const relativePath = path.relative(cwd, targetPath);
return regex.test(targetPath) || regex.test(relativePath) || targetPath.includes(resolvedPattern) || relativePath.includes(resolvedPattern);
}
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
const rulesPath = path.join(ctx.cwd, ".pi", "damage-control-rules.yaml");
try {
if (fs.existsSync(rulesPath)) {
const content = fs.readFileSync(rulesPath, "utf8");
const loaded = yamlParse(content) as Partial<Rules>;
rules = {
bashToolPatterns: loaded.bashToolPatterns || [],
zeroAccessPaths: loaded.zeroAccessPaths || [],
readOnlyPaths: loaded.readOnlyPaths || [],
noDeletePaths: loaded.noDeletePaths || [],
};
ctx.ui.notify(`🛡️ Damage-Control: Loaded ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} rules.`);
} else {
ctx.ui.notify("🛡️ Damage-Control: No rules found at .pi/damage-control-rules.yaml");
}
} catch (err) {
ctx.ui.notify(`🛡️ Damage-Control: Failed to load rules: ${err instanceof Error ? err.message : String(err)}`);
}
ctx.ui.setStatus(`🛡️ Damage-Control Active: ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} Rules`);
});
pi.on("tool_call", async (event, ctx) => {
let violationReason: string | null = null;
let shouldAsk = false;
// 1. Check Zero Access Paths for all tools that use path or glob
const checkPaths = (pathsToCheck: string[]) => {
for (const p of pathsToCheck) {
const resolved = resolvePath(p, ctx.cwd);
for (const zap of rules.zeroAccessPaths) {
if (isPathMatch(resolved, zap, ctx.cwd)) {
return `Access to zero-access path restricted: ${zap}`;
}
}
}
return null;
};
// Extract paths from tool input
const inputPaths: string[] = [];
if (isToolCallEventType("read", event) || isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
inputPaths.push(event.input.path);
} else if (isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)) {
inputPaths.push(event.input.path || ".");
}
if (isToolCallEventType("grep", event) && event.input.glob) {
// Check glob field as well
for (const zap of rules.zeroAccessPaths) {
if (event.input.glob.includes(zap) || isPathMatch(event.input.glob, zap, ctx.cwd)) {
violationReason = `Glob matches zero-access path: ${zap}`;
break;
}
}
}
if (!violationReason) {
violationReason = checkPaths(inputPaths);
}
// 2. Tool-specific logic
if (!violationReason) {
if (isToolCallEventType("bash", event)) {
const command = event.input.command;
// Check bashToolPatterns
for (const rule of rules.bashToolPatterns) {
const regex = new RegExp(rule.pattern);
if (regex.test(command)) {
violationReason = rule.reason;
shouldAsk = !!rule.ask;
break;
}
}
// Check if bash command interacts with restricted paths
if (!violationReason) {
for (const zap of rules.zeroAccessPaths) {
if (command.includes(zap)) {
violationReason = `Bash command references zero-access path: ${zap}`;
break;
}
}
}
if (!violationReason) {
for (const rop of rules.readOnlyPaths) {
// Heuristic: check if command might modify a read-only path
// Redirects, sed -i, rm, mv to, etc.
if (command.includes(rop) && (/[\s>|]/.test(command) || command.includes("rm") || command.includes("mv") || command.includes("sed"))) {
violationReason = `Bash command may modify read-only path: ${rop}`;
break;
}
}
}
if (!violationReason) {
for (const ndp of rules.noDeletePaths) {
if (command.includes(ndp) && (command.includes("rm") || command.includes("mv"))) {
violationReason = `Bash command attempts to delete/move protected path: ${ndp}`;
break;
}
}
}
} else if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
// Check Read-Only paths
for (const p of inputPaths) {
const resolved = resolvePath(p, ctx.cwd);
for (const rop of rules.readOnlyPaths) {
if (isPathMatch(resolved, rop, ctx.cwd)) {
violationReason = `Modification of read-only path restricted: ${rop}`;
break;
}
}
}
}
}
if (violationReason) {
if (shouldAsk) {
const confirmed = await ctx.ui.confirm("🛡️ Damage-Control Confirmation", `Dangerous command detected: ${violationReason}\n\nCommand: ${isToolCallEventType("bash", event) ? event.input.command : JSON.stringify(event.input)}\n\nDo you want to proceed?`, { timeout: 30000 });
if (!confirmed) {
ctx.ui.setStatus(`⚠️ Last Violation Blocked: ${violationReason.slice(0, 30)}...`);
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked_by_user" });
ctx.abort();
return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason} (User denied)\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` };
} else {
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "confirmed_by_user" });
return { block: false };
}
} else {
ctx.ui.notify(`🛑 Damage-Control: Blocked ${event.toolName} due to ${violationReason}`);
ctx.ui.setStatus(`⚠️ Last Violation: ${violationReason.slice(0, 30)}...`);
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked" });
ctx.abort();
return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason}\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` };
}
}
return { block: false };
});
}

View File

@@ -1,34 +0,0 @@
/**
* Minimal — Model name + context meter in a compact footer
*
* Shows model ID and a 10-block context usage bar: [###-------] 30%
*
* Usage: pi -e extensions/minimal.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(width: number): string[] {
const model = ctx.model?.id || "no-model";
const usage = ctx.getContextUsage();
const pct = (usage && usage.percent !== null) ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const left = theme.fg("dim", ` ${model}`);
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,633 +0,0 @@
/**
* Pi Pi — Meta-agent that builds Pi agents
*
* A team of domain-specific research experts (extensions, themes, skills,
* settings, TUI) operate in PARALLEL to gather documentation and patterns.
* The primary agent synthesizes their findings and WRITES the actual files.
*
* Each expert fetches fresh Pi documentation via firecrawl on first query.
* Experts are read-only researchers. The primary agent is the only writer.
*
* Commands:
* /experts — list available experts and their status
* /experts-grid N — set dashboard column count (default 3)
*
* Usage: pi -e extensions/pi-pi.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ────────────────────────────────────────
interface ExpertDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
file: string;
}
interface ExpertState {
def: ExpertDef;
status: "idle" | "researching" | "done" | "error";
question: string;
elapsed: number;
lastLine: string;
queryCount: number;
timer?: ReturnType<typeof setInterval>;
}
// ── Helpers ──────────────────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
function parseAgentFile(filePath: string): ExpertDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
if (!frontmatter.name) return null;
return {
name: frontmatter.name,
description: frontmatter.description || "",
tools: frontmatter.tools || "read,grep,find,ls",
systemPrompt: match[2].trim(),
file: filePath,
};
} catch {
return null;
}
}
// ── Expert card colors ────────────────────────────
// Each expert gets a unique hue: bg fills the card interior,
// br is the matching border foreground (brighter shade of same hue).
const EXPERT_COLORS: Record<string, { bg: string; br: string }> = {
"agent-expert": { bg: "\x1b[48;2;20;30;75m", br: "\x1b[38;2;70;110;210m" }, // navy
"config-expert": { bg: "\x1b[48;2;18;65;30m", br: "\x1b[38;2;55;175;90m" }, // forest
"ext-expert": { bg: "\x1b[48;2;80;18;28m", br: "\x1b[38;2;210;65;85m" }, // crimson
"keybinding-expert": { bg: "\x1b[48;2;50;22;85m", br: "\x1b[38;2;145;80;220m" }, // violet
"prompt-expert": { bg: "\x1b[48;2;80;55;12m", br: "\x1b[38;2;215;150;40m" }, // amber
"skill-expert": { bg: "\x1b[48;2;12;65;75m", br: "\x1b[38;2;40;175;195m" }, // teal
"theme-expert": { bg: "\x1b[48;2;80;18;62m", br: "\x1b[38;2;210;55;160m" }, // rose
"tui-expert": { bg: "\x1b[48;2;28;42;80m", br: "\x1b[38;2;85;120;210m" }, // slate
"cli-expert": { bg: "\x1b[48;2;60;80;20m", br: "\x1b[38;2;160;210;55m" }, // olive/lime
};
const FG_RESET = "\x1b[39m";
const BG_RESET = "\x1b[49m";
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const experts: Map<string, ExpertState> = new Map();
let gridCols = 3;
let widgetCtx: any;
function loadExperts(cwd: string) {
// Pi Pi experts live in their own dedicated directory
const piPiDir = join(cwd, ".pi", "agents", "pi-pi");
experts.clear();
if (!existsSync(piPiDir)) return;
try {
for (const file of readdirSync(piPiDir)) {
if (!file.endsWith(".md")) continue;
if (file === "pi-orchestrator.md") continue;
const fullPath = resolve(piPiDir, file);
const def = parseAgentFile(fullPath);
if (def) {
const key = def.name.toLowerCase();
if (!experts.has(key)) {
experts.set(key, {
def,
status: "idle",
question: "",
elapsed: 0,
lastLine: "",
queryCount: 0,
});
}
}
}
} catch {}
}
// ── Grid Rendering ───────────────────────────
function renderCard(state: ExpertState, colWidth: number, theme: any): string[] {
const w = colWidth - 2;
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
const statusColor = state.status === "idle" ? "dim"
: state.status === "researching" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "idle" ? "○"
: state.status === "researching" ? "◉"
: state.status === "done" ? "✓" : "✗";
const name = displayName(state.def.name);
const nameStr = theme.fg("accent", theme.bold(truncate(name, w)));
const nameVisible = Math.min(name.length, w);
const statusStr = `${statusIcon} ${state.status}`;
const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
const queriesStr = state.queryCount > 0 ? ` (${state.queryCount})` : "";
const statusLine = theme.fg(statusColor, statusStr + timeStr + queriesStr);
const statusVisible = statusStr.length + timeStr.length + queriesStr.length;
const workRaw = state.question || state.def.description;
const workText = truncate(workRaw, Math.min(50, w - 1));
const workLine = theme.fg("muted", workText);
const workVisible = workText.length;
const lastRaw = state.lastLine || "";
const lastText = truncate(lastRaw, Math.min(50, w - 1));
const lastLineRendered = lastText ? theme.fg("dim", lastText) : theme.fg("dim", "—");
const lastVisible = lastText ? lastText.length : 1;
const colors = EXPERT_COLORS[state.def.name];
const bg = colors?.bg ?? "";
const br = colors?.br ?? "";
const bgr = bg ? BG_RESET : "";
const fgr = br ? FG_RESET : "";
// br colors the box-drawing characters; bg fills behind them so the
// full card — top line, side bars, bottom line — is one solid block.
const bord = (s: string) => bg + br + s + bgr + fgr;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
// bg fills the inner content area; re-applied before padding to ensure
// the full row is colored even if theme.fg uses a full ANSI reset inside.
const border = (content: string, visLen: number) => {
const pad = " ".repeat(Math.max(0, w - visLen));
return bord("│") + bg + content + bg + pad + bgr + bord("│");
};
return [
bord(top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + workLine, 1 + workVisible),
border(" " + lastLineRendered, 1 + lastVisible),
bord(bot),
];
}
function updateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("pi-pi-grid", (_tui: any, theme: any) => {
return {
render(width: number): string[] {
if (experts.size === 0) {
return ["", theme.fg("dim", " No experts found. Add agent .md files to .pi/agents/pi-pi/")];
}
const cols = Math.min(gridCols, experts.size);
const gap = 1;
// avoid Text component's ANSI-width miscounting by returning raw lines
const colWidth = Math.floor((width - gap * (cols - 1)) / cols) - 1;
const allExperts = Array.from(experts.values());
const lines: string[] = [""]; // top margin
for (let i = 0; i < allExperts.length; i += cols) {
const rowExperts = allExperts.slice(i, i + cols);
const cards = rowExperts.map(e => renderCard(e, colWidth, theme));
while (cards.length < cols) {
cards.push(Array(6).fill(" ".repeat(colWidth)));
}
const cardHeight = cards[0].length;
for (let line = 0; line < cardHeight; line++) {
lines.push(cards.map(card => card[line] || "").join(" ".repeat(gap)));
}
}
return lines;
},
invalidate() {},
};
});
}
// ── Query Expert ─────────────────────────────
function queryExpert(
expertName: string,
question: string,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const key = expertName.toLowerCase();
const state = experts.get(key);
if (!state) {
return Promise.resolve({
output: `Expert "${expertName}" not found. Available: ${Array.from(experts.values()).map(s => s.def.name).join(", ")}`,
exitCode: 1,
elapsed: 0,
});
}
if (state.status === "researching") {
return Promise.resolve({
output: `Expert "${displayName(state.def.name)}" is already researching. Wait for it to finish.`,
exitCode: 1,
elapsed: 0,
});
}
state.status = "researching";
state.question = question;
state.elapsed = 0;
state.lastLine = "";
state.queryCount++;
updateWidget();
const startTime = Date.now();
state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidget();
}, 1000);
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
const args = [
"--mode", "json",
"-p",
"--no-session",
"--no-extensions",
"--model", model,
"--tools", state.def.tools,
"--thinking", "off",
"--append-system-prompt", state.def.systemPrompt,
question,
];
const textChunks: string[] = [];
return new Promise((resolve) => {
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
let buffer = "";
proc.stdout!.setEncoding("utf-8");
proc.stdout!.on("data", (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
textChunks.push(delta.delta || "");
const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastLine = last;
updateWidget();
}
}
} catch {}
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", () => {});
proc.on("close", (code) => {
if (buffer.trim()) {
try {
const event = JSON.parse(buffer);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
}
} catch {}
}
clearInterval(state.timer);
state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
const full = textChunks.join("");
state.lastLine = full.split("\n").filter((l: string) => l.trim()).pop() || "";
updateWidget();
ctx.ui.notify(
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
state.status === "done" ? "success" : "error"
);
resolve({
output: full,
exitCode: code ?? 1,
elapsed: state.elapsed,
});
});
proc.on("error", (err) => {
clearInterval(state.timer);
state.status = "error";
state.lastLine = `Error: ${err.message}`;
updateWidget();
resolve({
output: `Error spawning expert: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── query_experts Tool (parallel) ───────────
pi.registerTool({
name: "query_experts",
label: "Query Experts",
description: `Query one or more Pi domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
Available experts:
- ext-expert: Extensions — tools, events, commands, rendering, state management
- theme-expert: Themes — JSON format, 51 color tokens, vars, color values
- skill-expert: Skills — SKILL.md multi-file packages, scripts, references, frontmatter
- config-expert: Settings — settings.json, providers, models, packages, keybindings
- tui-expert: TUI — components, keyboard input, overlays, widgets, footers, editors
- prompt-expert: Prompt templates — single-file .md commands, arguments ($1, $@)
- agent-expert: Agent definitions — .md personas, tools, teams.yaml, orchestration
- keybinding-expert: Keyboard shortcuts — registerShortcut(), Key IDs, reserved keys, macOS terminal compatibility
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
parameters: Type.Object({
queries: Type.Array(
Type.Object({
expert: Type.String({
description: "Expert name: ext-expert, theme-expert, skill-expert, config-expert, tui-expert, prompt-expert, or agent-expert",
}),
question: Type.String({
description: "Specific question about what you need to build. Include context about the target component.",
}),
}),
{ description: "Array of expert queries to run in parallel" },
),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { queries } = params as { queries: { expert: string; question: string }[] };
if (!queries || queries.length === 0) {
return {
content: [{ type: "text", text: "No queries provided." }],
details: { results: [], status: "error" },
};
}
const names = queries.map(q => displayName(q.expert)).join(", ");
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Querying ${queries.length} experts in parallel: ${names}` }],
details: { queries, status: "researching", results: [] },
});
}
// Launch ALL experts concurrently — allSettled so one failure
// never discards results from the others
const settled = await Promise.allSettled(
queries.map(async ({ expert, question }) => {
const result = await queryExpert(expert, question, ctx);
const truncated = result.output.length > 12000
? result.output.slice(0, 12000) + "\n\n... [truncated — ask follow-up for more]"
: result.output;
const status = result.exitCode === 0 ? "done" : "error";
return {
expert,
question,
status,
elapsed: result.elapsed,
exitCode: result.exitCode,
output: truncated,
fullOutput: result.output,
};
}),
);
const results = settled.map((s, i) =>
s.status === "fulfilled"
? s.value
: {
expert: queries[i].expert,
question: queries[i].question,
status: "error" as const,
elapsed: 0,
exitCode: 1,
output: `Error: ${(s.reason as any)?.message || s.reason}`,
fullOutput: "",
},
);
// Build combined response
const sections = results.map(r => {
const icon = r.status === "done" ? "✓" : "✗";
return `## [${icon}] ${displayName(r.expert)} (${Math.round(r.elapsed / 1000)}s)\n\n${r.output}`;
});
return {
content: [{ type: "text", text: sections.join("\n\n---\n\n") }],
details: {
results,
status: results.every(r => r.status === "done") ? "done" : "partial",
},
};
},
renderCall(args, theme) {
const queries = (args as any).queries || [];
const names = queries.map((q: any) => displayName(q.expert || "?")).join(", ");
return new Text(
theme.fg("toolTitle", theme.bold("query_experts ")) +
theme.fg("accent", `${queries.length} parallel`) +
theme.fg("dim", " — ") +
theme.fg("muted", names),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details?.results) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "researching") {
const count = details.queries?.length || "?";
return new Text(
theme.fg("accent", `${count} experts`) +
theme.fg("dim", " researching in parallel..."),
0, 0,
);
}
const lines = (details.results as any[]).map((r: any) => {
const icon = r.status === "done" ? "✓" : "✗";
const color = r.status === "done" ? "success" : "error";
const elapsed = typeof r.elapsed === "number" ? Math.round(r.elapsed / 1000) : 0;
return theme.fg(color, `${icon} ${displayName(r.expert)}`) +
theme.fg("dim", ` ${elapsed}s`);
});
const header = lines.join(theme.fg("dim", " · "));
if (options.expanded && details.results) {
const expanded = (details.results as any[]).map((r: any) => {
const output = r.fullOutput
? (r.fullOutput.length > 4000 ? r.fullOutput.slice(0, 4000) + "\n... [truncated]" : r.fullOutput)
: r.output || "";
return theme.fg("accent", `── ${displayName(r.expert)} ──`) + "\n" + theme.fg("muted", output);
});
return new Text(header + "\n\n" + expanded.join("\n\n"), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("experts", {
description: "List available Pi Pi experts and their status",
handler: async (_args, _ctx) => {
widgetCtx = _ctx;
const lines = Array.from(experts.values())
.map(s => `${displayName(s.def.name)} (${s.status}, queries: ${s.queryCount}): ${s.def.description}`)
.join("\n");
_ctx.ui.notify(lines || "No experts loaded", "info");
},
});
pi.registerCommand("experts-grid", {
description: "Set expert grid columns: /experts-grid <1-5>",
handler: async (args, _ctx) => {
widgetCtx = _ctx;
const n = parseInt(args?.trim() || "", 10);
if (n >= 1 && n <= 5) {
gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
updateWidget();
} else {
_ctx.ui.notify("Usage: /experts-grid <1-5>", "error");
}
},
});
// ── System Prompt ────────────────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
const expertCatalog = Array.from(experts.values())
.map(s => `### ${displayName(s.def.name)}\n**Query as:** \`${s.def.name}\`\n${s.def.description}`)
.join("\n\n");
const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", ");
const orchestratorPath = join(_ctx.cwd, ".pi", "agents", "pi-pi", "pi-orchestrator.md");
let systemPrompt = "";
try {
const raw = readFileSync(orchestratorPath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
const template = match ? match[2].trim() : raw;
systemPrompt = template
.replace("{{EXPERT_COUNT}}", experts.size.toString())
.replace("{{EXPERT_NAMES}}", expertNames)
.replace("{{EXPERT_CATALOG}}", expertCatalog);
} catch (err) {
systemPrompt = "Error: Could not load pi-orchestrator.md. Make sure it exists in .pi/agents/pi-pi/.";
}
return { systemPrompt };
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
if (widgetCtx) {
widgetCtx.ui.setWidget("pi-pi-grid", undefined);
}
widgetCtx = _ctx;
loadExperts(_ctx.cwd);
updateWidget();
const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", ");
_ctx.ui.setStatus("pi-pi", `Pi Pi (${experts.size} experts)`);
_ctx.ui.notify(
`Pi Pi loaded — ${experts.size} experts: ${expertNames}\n\n` +
`/experts List experts and status\n` +
`/experts-grid N Set grid columns (1-5)\n\n` +
`Ask me to build any Pi agent component!`,
"info",
);
// Custom footer
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(width: number): string[] {
const model = _ctx.model?.id || "no-model";
const usage = _ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const active = Array.from(experts.values()).filter(e => e.status === "researching").length;
const done = Array.from(experts.values()).filter(e => e.status === "done").length;
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
theme.fg("accent", "Pi Pi");
const mid = active > 0
? theme.fg("accent", `${active} researching`)
: done > 0
? theme.fg("success", `${done} done`)
: "";
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(mid) - visibleWidth(right)));
return [truncateToWidth(left + mid + pad + right, width)];
},
}));
});
}

View File

@@ -1,24 +0,0 @@
/**
* Pure Focus — Strip all footer and status line UI
*
* Removes the footer bar and status line entirely, leaving only
* the conversation and editor. Pure distraction-free mode.
*
* Usage: pi -e examples/extensions/pure-focus.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setFooter((_tui, _theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(_width: number): string[] {
return [];
},
}));
});
}

View File

@@ -1,84 +0,0 @@
/**
* Purpose Gate — Forces the engineer to declare intent before working
*
* On session start, immediately asks "What is the purpose of this agent?"
* via a text input dialog. A persistent widget shows the purpose for the
* rest of the session, keeping focus. Blocks all prompts until answered.
*
* Usage: pi -e extensions/purpose-gate.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
// synthwave: bgWarm #4a1e6a → rgb(74,30,106)
function bg(s: string): string {
return `\x1b[48;2;74;30;106m${s}\x1b[49m`;
}
// synthwave: pink #ff7edb
function pink(s: string): string {
return `\x1b[38;2;255;126;219m${s}\x1b[39m`;
}
// synthwave: cyan #36f9f6
function cyan(s: string): string {
return `\x1b[38;2;54;249;246m${s}\x1b[39m`;
}
function bold(s: string): string {
return `\x1b[1m${s}\x1b[22m`;
}
export default function (pi: ExtensionAPI) {
let purpose: string | undefined;
async function askForPurpose(ctx: any) {
while (!purpose) {
const answer = await ctx.ui.input(
"What is the purpose of this agent?",
"e.g. Refactor the auth module to use JWT"
);
if (answer && answer.trim()) {
purpose = answer.trim();
} else {
ctx.ui.notify("Purpose is required.", "warning");
}
}
ctx.ui.setWidget("purpose", () => {
return {
render(width: number): string[] {
const pad = bg(" ".repeat(width));
const label = pink(bold(" PURPOSE: "));
const msg = cyan(bold(purpose!));
const content = bg(truncateToWidth(label + msg + " ".repeat(width), width, ""));
return [pad, content, pad];
},
invalidate() {},
};
});
}
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
void askForPurpose(ctx);
});
pi.on("before_agent_start", async (event) => {
if (!purpose) return;
return {
systemPrompt: event.systemPrompt + `\n\n<purpose>\nYour singular purpose this session: ${purpose}\nStay focused on this goal. If a request drifts from this purpose, gently remind the user.\n</purpose>`,
};
});
pi.on("input", async (_event, ctx) => {
if (!purpose) {
ctx.ui.notify("Set a purpose first.", "warning");
return { action: "handled" as const };
}
return { action: "continue" as const };
});
}

View File

@@ -1,216 +0,0 @@
import { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
import {
Box, Text, Markdown, Container, Spacer,
matchesKey, Key, truncateToWidth, getMarkdownTheme
} from "@mariozechner/pi-tui";
import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent";
// Minimal shim for timestamp handling if not directly in Message objects
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function getElapsedTime(start: Date, end: Date): string {
const diffMs = end.getTime() - start.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
return `${diffMin}m ${diffSec % 60}s`;
}
interface HistoryItem {
type: 'user' | 'assistant' | 'tool';
title: string;
content: string;
timestamp: Date;
elapsed?: string;
}
class SessionReplayUI {
private selectedIndex = 0;
private expandedIndex: number | null = null;
private scrollOffset = 0;
constructor(
private items: HistoryItem[],
private onDone: () => void
) {
// Start selected at the bottom (most recent)
this.selectedIndex = Math.max(0, items.length - 1);
this.ensureVisible(20); // rough height estimate
}
handleInput(data: string, tui: any): void {
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
} else if (matchesKey(data, Key.enter)) {
this.expandedIndex = this.expandedIndex === this.selectedIndex ? null : this.selectedIndex;
} else if (matchesKey(data, Key.escape)) {
this.onDone();
return;
}
tui.requestRender();
}
private ensureVisible(height: number) {
// Simple scroll window logic
const pageSize = Math.floor(height / 3); // Approx items per page
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + pageSize) {
this.scrollOffset = this.selectedIndex - pageSize + 1;
}
}
render(width: number, height: number, theme: any): string[] {
this.ensureVisible(height);
const container = new Container();
const mdTheme = getPiMdTheme();
// Header
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(`${theme.fg("accent", theme.bold(" SESSION REPLAY"))} ${theme.fg("dim", "|")} ${theme.fg("success", this.items.length.toString())} entries`, 1, 0));
container.addChild(new Spacer(1));
// Calculate visible range
const visibleItems = this.items.slice(this.scrollOffset);
visibleItems.forEach((item, idx) => {
const absoluteIndex = idx + this.scrollOffset;
const isSelected = absoluteIndex === this.selectedIndex;
const isExpanded = absoluteIndex === this.expandedIndex;
const cardBox = new Box(1, 0, (s) => isSelected ? theme.bg("selectedBg", s) : s);
// Icon and Title
let icon = "○";
let color = "dim";
if (item.type === 'user') { icon = "👤"; color = "success"; }
else if (item.type === 'assistant') { icon = "🤖"; color = "accent"; }
else if (item.type === 'tool') { icon = "🛠️"; color = "warning"; }
const timeStr = theme.fg("success", `[${formatTime(item.timestamp)}]`);
const elapsedStr = item.elapsed ? theme.fg("dim", ` (+${item.elapsed})`) : "";
const titleLine = `${theme.fg(color, icon)} ${theme.bold(item.title)} ${timeStr}${elapsedStr}`;
cardBox.addChild(new Text(titleLine, 0, 0));
if (isExpanded) {
cardBox.addChild(new Spacer(1));
cardBox.addChild(new Markdown(item.content, 2, 0, mdTheme));
} else {
// Truncated preview
const preview = item.content.replace(/\n/g, ' ').substring(0, width - 10);
cardBox.addChild(new Text(theme.fg("dim", " " + preview + "..."), 0, 0));
}
container.addChild(cardBox);
// Don't add too many spacers if we have many items
if (visibleItems.length < 15) container.addChild(new Spacer(1));
});
// Footer
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", " ↑/↓ Navigate • Enter Expand • Esc Close"), 1, 0));
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return container.render(width);
}
}
function extractContent(entry: any): string {
const msg = entry.message;
if (!msg) return "";
const content = msg.content;
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c: any) => {
if (c.type === "text") return c.text || "";
if (c.type === "toolCall") return `Tool: ${c.name}(${JSON.stringify(c.arguments).slice(0, 200)})`;
return "";
})
.filter(Boolean)
.join("\n");
}
return JSON.stringify(content).slice(0, 500);
}
export default function(pi: ExtensionAPI) {
pi.registerCommand("replay", {
description: "Show a scrollable timeline of the current session",
handler: async (args, ctx) => {
const branch = ctx.sessionManager.getBranch();
const items: HistoryItem[] = [];
let prevTime: Date | null = null;
for (const entry of branch) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (!msg) continue;
const ts = msg.timestamp ? new Date(msg.timestamp) : new Date();
const elapsed = prevTime ? getElapsedTime(prevTime, ts) : undefined;
prevTime = ts;
const role = msg.role;
const text = extractContent(entry);
if (!text) continue;
if (role === "user") {
items.push({
type: "user",
title: "User Prompt",
content: text,
timestamp: ts,
elapsed,
});
} else if (role === "assistant") {
items.push({
type: "assistant",
title: "Assistant",
content: text,
timestamp: ts,
elapsed,
});
} else if (role === "toolResult") {
const toolName = (msg as any).toolName || "tool";
items.push({
type: "tool",
title: `Tool: ${toolName}`,
content: text,
timestamp: ts,
elapsed,
});
}
}
if (items.length === 0) {
ctx.ui.notify("No session history found.", "warning");
return;
}
await ctx.ui.custom((tui, theme, kb, done) => {
const component = new SessionReplayUI(items, () => done(undefined));
return {
render: (w) => component.render(w, 30, theme),
handleInput: (data) => component.handleInput(data, tui),
invalidate: () => {},
};
}, {
overlay: true,
overlayOptions: { width: "80%", anchor: "center" },
});
},
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
});
}

View File

@@ -1,41 +0,0 @@
/**
* Stop — Immediately interrupt the active chat session
*
* Registers a /stop slash command that aborts the current agent turn.
* Also supports /stop with a reason message for logging clarity.
*
* Usage: pi -e extensions/stop.ts
*
* Commands:
* /stop — abort the current agent turn immediately
* /stop <reason> — abort with a logged reason
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
let activeCtx: ExtensionContext | undefined;
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
activeCtx = ctx;
});
pi.on("session_switch", async (_event, ctx) => {
activeCtx = ctx;
});
pi.registerCommand("stop", {
description: "Immediately interrupt the active agent turn. Usage: /stop [reason]",
handler: async (args, ctx) => {
activeCtx = ctx;
const reason = (args || "").trim();
ctx.abort();
const msg = reason
? `🛑 Session aborted: ${reason}`
: "🛑 Session aborted.";
ctx.ui.notify(msg, "warning");
},
});
}

View File

@@ -1,481 +0,0 @@
/**
* Subagent Widget — /sub, /subclear, /subrm, /subcont commands with stacking live widgets
*
* Each /sub spawns a background Pi subagent with its own persistent session,
* enabling conversation continuations via /subcont.
*
* Usage: pi -e extensions/subagent-widget.ts
* Then:
* /sub list files and summarize — spawn a new subagent
* /subcont 1 now write tests for it — continue subagent #1's conversation
* /subrm 2 — remove subagent #2 widget
* /subclear — clear all subagent widgets
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
const { spawn } = require("child_process") as any;
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
interface SubState {
id: number;
status: "running" | "done" | "error";
task: string;
textChunks: string[];
toolCount: number;
elapsed: number;
sessionFile: string; // persistent JSONL session path — used by /subcont to resume
turnCount: number; // increments each time /subcont continues this agent
proc?: any; // active ChildProcess ref (for kill on /subrm)
}
export default function (pi: ExtensionAPI) {
const agents: Map<number, SubState> = new Map();
let nextId = 1;
let widgetCtx: any;
// ── Session file helpers ──────────────────────────────────────────────────
function makeSessionFile(id: number): string {
const dir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, `subagent-${id}-${Date.now()}.jsonl`);
}
// ── Widget rendering ──────────────────────────────────────────────────────
function updateWidgets() {
if (!widgetCtx) return;
for (const [id, state] of Array.from(agents.entries())) {
const key = `sub-${id}`;
widgetCtx.ui.setWidget(key, (_tui: any, theme: any) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("dim", s);
container.addChild(new Text("", 0, 0)); // top margin
container.addChild(new DynamicBorder(borderFn));
const content = new Text("", 1, 0);
container.addChild(content);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const lines: string[] = [];
const statusColor = state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const taskPreview = state.task.length > 40
? state.task.slice(0, 37) + "..."
: state.task;
const turnLabel = state.turnCount > 1
? theme.fg("dim", ` · Turn ${state.turnCount}`)
: "";
lines.push(
theme.fg(statusColor, `${statusIcon} Subagent #${state.id}`) +
turnLabel +
theme.fg("dim", ` ${taskPreview}`) +
theme.fg("dim", ` (${Math.round(state.elapsed / 1000)}s)`) +
theme.fg("dim", ` | Tools: ${state.toolCount}`)
);
const fullText = state.textChunks.join("");
const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || "";
if (lastLine) {
const trimmed = lastLine.length > width - 10
? lastLine.slice(0, width - 13) + "..."
: lastLine;
lines.push(theme.fg("muted", ` ${trimmed}`));
}
content.setText(lines.join("\n"));
return container.render(width);
},
invalidate() {
container.invalidate();
},
};
});
}
}
// ── Streaming helpers ─────────────────────────────────────────────────────
function processLine(state: SubState, line: string) {
if (!line.trim()) return;
try {
const event = JSON.parse(line);
const type = event.type;
if (type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
state.textChunks.push(delta.delta || "");
updateWidgets();
}
} else if (type === "tool_execution_start") {
state.toolCount++;
updateWidgets();
}
} catch {}
}
function spawnAgent(
state: SubState,
prompt: string,
ctx: any,
): Promise<void> {
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
return new Promise<void>((resolve) => {
const proc = spawn("pi", [
"--mode", "json",
"-p",
"--session", state.sessionFile, // persistent session for /subcont resumption
"--no-extensions",
"--model", model,
"--tools", "read,bash,grep,find,ls",
"--thinking", "off",
prompt,
], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
state.proc = proc;
const startTime = Date.now();
const timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidgets();
}, 1000);
let buffer = "";
proc.stdout!.setEncoding("utf-8");
proc.stdout!.on("data", (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(state, line);
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", (chunk: string) => {
if (chunk.trim()) {
state.textChunks.push(chunk);
updateWidgets();
}
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(state, buffer);
clearInterval(timer);
state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
state.proc = undefined;
updateWidgets();
const result = state.textChunks.join("");
ctx.ui.notify(
`Subagent #${state.id} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
state.status === "done" ? "success" : "error"
);
pi.sendMessage({
customType: "subagent-result",
content: `Subagent #${state.id}${state.turnCount > 1 ? ` (Turn ${state.turnCount})` : ""} finished "${prompt}" in ${Math.round(state.elapsed / 1000)}s.\n\nResult:\n${result.slice(0, 8000)}${result.length > 8000 ? "\n\n... [truncated]" : ""}`,
display: true,
}, { deliverAs: "followUp", triggerTurn: true });
resolve();
});
proc.on("error", (err) => {
clearInterval(timer);
state.status = "error";
state.proc = undefined;
state.textChunks.push(`Error: ${err.message}`);
updateWidgets();
resolve();
});
});
}
// ── Tools for the Main Agent ──────────────────────────────────────────────
pi.registerTool({
name: "subagent_create",
description: "Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results will be delivered as a follow-up message when finished.",
parameters: Type.Object({
task: Type.String({ description: "The complete task description for the subagent to perform" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const id = nextId++;
const state: SubState = {
id,
status: "running",
task: args.task,
textChunks: [],
toolCount: 0,
elapsed: 0,
sessionFile: makeSessionFile(id),
turnCount: 1,
};
agents.set(id, state);
updateWidgets();
// Fire-and-forget
spawnAgent(state, args.task, ctx);
return {
content: [{ type: "text", text: `Subagent #${id} spawned and running in background.` }],
};
},
});
pi.registerTool({
name: "subagent_continue",
description: "Continue an existing subagent's conversation. Use this to give further instructions to a finished subagent. Returns immediately while it runs in the background.",
parameters: Type.Object({
id: Type.Number({ description: "The ID of the subagent to continue" }),
prompt: Type.String({ description: "The follow-up prompt or new instructions" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const state = agents.get(args.id);
if (!state) {
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
}
if (state.status === "running") {
return { content: [{ type: "text", text: `Error: Subagent #${args.id} is still running.` }] };
}
state.status = "running";
state.task = args.prompt;
state.textChunks = [];
state.elapsed = 0;
state.turnCount++;
updateWidgets();
ctx.ui.notify(`Continuing Subagent #${args.id} (Turn ${state.turnCount})…`, "info");
spawnAgent(state, args.prompt, ctx);
return {
content: [{ type: "text", text: `Subagent #${args.id} continuing conversation in background.` }],
};
},
});
pi.registerTool({
name: "subagent_remove",
description: "Remove a specific subagent. Kills it if it's currently running.",
parameters: Type.Object({
id: Type.Number({ description: "The ID of the subagent to remove" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const state = agents.get(args.id);
if (!state) {
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
}
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
}
ctx.ui.setWidget(`sub-${args.id}`, undefined);
agents.delete(args.id);
return {
content: [{ type: "text", text: `Subagent #${args.id} removed successfully.` }],
};
},
});
pi.registerTool({
name: "subagent_list",
description: "List all active and finished subagents, showing their IDs, tasks, and status.",
parameters: Type.Object({}),
execute: async () => {
if (agents.size === 0) {
return { content: [{ type: "text", text: "No active subagents." }] };
}
const list = Array.from(agents.values()).map(s =>
`#${s.id} [${s.status.toUpperCase()}] (Turn ${s.turnCount}) - ${s.task}`
).join("\n");
return {
content: [{ type: "text", text: `Subagents:\n${list}` }],
};
},
});
// ── /sub <task> ───────────────────────────────────────────────────────────
pi.registerCommand("sub", {
description: "Spawn a subagent with live widget: /sub <task>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const task = args?.trim();
if (!task) {
ctx.ui.notify("Usage: /sub <task>", "error");
return;
}
const id = nextId++;
const state: SubState = {
id,
status: "running",
task,
textChunks: [],
toolCount: 0,
elapsed: 0,
sessionFile: makeSessionFile(id),
turnCount: 1,
};
agents.set(id, state);
updateWidgets();
// Fire-and-forget
spawnAgent(state, task, ctx);
},
});
// ── /subcont <number> <prompt> ────────────────────────────────────────────
pi.registerCommand("subcont", {
description: "Continue an existing subagent's conversation: /subcont <number> <prompt>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const trimmed = args?.trim() ?? "";
const spaceIdx = trimmed.indexOf(" ");
if (spaceIdx === -1) {
ctx.ui.notify("Usage: /subcont <number> <prompt>", "error");
return;
}
const num = parseInt(trimmed.slice(0, spaceIdx), 10);
const prompt = trimmed.slice(spaceIdx + 1).trim();
if (isNaN(num) || !prompt) {
ctx.ui.notify("Usage: /subcont <number> <prompt>", "error");
return;
}
const state = agents.get(num);
if (!state) {
ctx.ui.notify(`No subagent #${num} found. Use /sub to create one.`, "error");
return;
}
if (state.status === "running") {
ctx.ui.notify(`Subagent #${num} is still running — wait for it to finish first.`, "warning");
return;
}
// Resume: update state for a new turn
state.status = "running";
state.task = prompt;
state.textChunks = [];
state.elapsed = 0;
state.turnCount++;
updateWidgets();
ctx.ui.notify(`Continuing Subagent #${num} (Turn ${state.turnCount})…`, "info");
// Fire-and-forget — reuses the same sessionFile for conversation history
spawnAgent(state, prompt, ctx);
},
});
// ── /subrm <number> ───────────────────────────────────────────────────────
pi.registerCommand("subrm", {
description: "Remove a specific subagent widget: /subrm <number>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const num = parseInt(args?.trim() ?? "", 10);
if (isNaN(num)) {
ctx.ui.notify("Usage: /subrm <number>", "error");
return;
}
const state = agents.get(num);
if (!state) {
ctx.ui.notify(`No subagent #${num} found.`, "error");
return;
}
// Kill the process if still running
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
ctx.ui.notify(`Subagent #${num} killed and removed.`, "warning");
} else {
ctx.ui.notify(`Subagent #${num} removed.`, "info");
}
ctx.ui.setWidget(`sub-${num}`, undefined);
agents.delete(num);
},
});
// ── /subclear ─────────────────────────────────────────────────────────────
pi.registerCommand("subclear", {
description: "Clear all subagent widgets",
handler: async (_args, ctx) => {
widgetCtx = ctx;
let killed = 0;
for (const [id, state] of Array.from(agents.entries())) {
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
killed++;
}
ctx.ui.setWidget(`sub-${id}`, undefined);
}
const total = agents.size;
agents.clear();
nextId = 1;
const msg = total === 0
? "No subagents to clear."
: `Cleared ${total} subagent${total !== 1 ? "s" : ""}${killed > 0 ? ` (${killed} killed)` : ""}.`;
ctx.ui.notify(msg, total === 0 ? "info" : "success");
},
});
// ── Session lifecycle ─────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
for (const [id, state] of Array.from(agents.entries())) {
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
}
ctx.ui.setWidget(`sub-${id}`, undefined);
}
agents.clear();
nextId = 1;
widgetCtx = ctx;
});
}

View File

@@ -1,167 +0,0 @@
/**
* System Select — Switch the system prompt via /system
*
* Scans .pi/agents/, .claude/agents/, .gemini/agents/, .codex/agents/
* (project-local and global) for agent definition .md files.
*
* /system opens a select dialog to pick a system prompt. The selected
* agent's body is prepended to Pi's default instructions so tool usage
* still works. Tools are restricted to the agent's declared tool set
* if specified.
*
* Usage: pi -e extensions/system-select.ts -e extensions/minimal.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { applyExtensionDefaults } from "./themeMap.ts";
interface AgentDef {
name: string;
description: string;
tools: string[];
body: string;
source: string;
}
function parseFrontmatter(raw: string): { fields: Record<string, string>; body: string } {
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) return { fields: {}, body: raw };
const fields: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return { fields, body: match[2] };
}
function scanAgents(dir: string, source: string): AgentDef[] {
if (!existsSync(dir)) return [];
const agents: AgentDef[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { fields, body } = parseFrontmatter(raw);
agents.push({
name: fields.name || basename(file, ".md"),
description: fields.description || "",
tools: fields.tools ? fields.tools.split(",").map((t) => t.trim()) : [],
body: body.trim(),
source,
});
}
} catch {}
return agents;
}
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
export default function (pi: ExtensionAPI) {
let activeAgent: AgentDef | null = null;
let allAgents: AgentDef[] = [];
let defaultTools: string[] = [];
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
activeAgent = null;
allAgents = [];
const home = homedir();
const cwd = ctx.cwd;
const dirs: [string, string][] = [
[join(cwd, ".pi", "agents"), ".pi"],
[join(cwd, ".claude", "agents"), ".claude"],
[join(cwd, ".gemini", "agents"), ".gemini"],
[join(cwd, ".codex", "agents"), ".codex"],
[join(home, ".pi", "agent", "agents"), "~/.pi"],
[join(home, ".claude", "agents"), "~/.claude"],
[join(home, ".gemini", "agents"), "~/.gemini"],
[join(home, ".codex", "agents"), "~/.codex"],
];
const seen = new Set<string>();
const sourceCounts: Record<string, number> = {};
for (const [dir, source] of dirs) {
const agents = scanAgents(dir, source);
for (const agent of agents) {
const key = agent.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allAgents.push(agent);
sourceCounts[source] = (sourceCounts[source] || 0) + 1;
}
}
defaultTools = pi.getActiveTools();
ctx.ui.setStatus("system-prompt", "System Prompt: Default");
const defaultPrompt = ctx.getSystemPrompt();
const lines = defaultPrompt.split("\n").length;
const chars = defaultPrompt.length;
const loadedSources = Object.entries(sourceCounts)
.map(([src, count]) => `${count} from ${src}`)
.join(", ");
const notifyLines = [];
if (allAgents.length > 0) {
notifyLines.push(`Loaded ${allAgents.length} agents (${loadedSources})`);
}
notifyLines.push(`System Prompt: Default (${lines} lines, ${chars} chars)`);
ctx.ui.notify(notifyLines.join("\n"), "info");
});
pi.registerCommand("system", {
description: "Select a system prompt from discovered agents",
handler: async (_args, ctx) => {
if (allAgents.length === 0) {
ctx.ui.notify("No agents found in .*/agents/*.md", "warning");
return;
}
const options = [
"Reset to Default",
...allAgents.map((a) => `${a.name}${a.description} [${a.source}]`),
];
const choice = await ctx.ui.select("Select System Prompt", options);
if (choice === undefined) return;
if (choice === options[0]) {
activeAgent = null;
pi.setActiveTools(defaultTools);
ctx.ui.setStatus("system-prompt", "System Prompt: Default");
ctx.ui.notify("System Prompt reset to Default", "success");
return;
}
const idx = options.indexOf(choice) - 1;
const agent = allAgents[idx];
activeAgent = agent;
if (agent.tools.length > 0) {
pi.setActiveTools(agent.tools);
} else {
pi.setActiveTools(defaultTools);
}
ctx.ui.setStatus("system-prompt", `System Prompt: ${displayName(agent.name)}`);
ctx.ui.notify(`System Prompt switched to: ${displayName(agent.name)}`, "success");
},
});
pi.on("before_agent_start", async (event, _ctx) => {
if (!activeAgent) return;
return {
systemPrompt: activeAgent.body + "\n\n" + event.systemPrompt,
};
});
}

View File

@@ -1,181 +0,0 @@
/**
* Theme Cycler — Keyboard shortcuts to cycle through available themes
*
* Shortcuts:
* Ctrl+X — Cycle theme forward
* Ctrl+Q — Cycle theme backward
*
* Commands:
* /theme — Open select picker to choose a theme
* /theme <name> — Switch directly by name
*
* Features:
* - Status line shows current theme name with accent color
* - Color swatch widget flashes briefly after each switch
* - Auto-dismisses swatch after 3 seconds
*
* Usage: pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
let currentCtx: ExtensionContext | undefined;
let swatchTimer: ReturnType<typeof setTimeout> | null = null;
function updateStatus(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
const name = ctx.ui.theme.name;
ctx.ui.setStatus("theme", `🎨 ${name}`);
}
function showSwatch(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
if (swatchTimer) {
clearTimeout(swatchTimer);
swatchTimer = null;
}
ctx.ui.setWidget(
"theme-swatch",
(_tui, theme) => ({
invalidate() {},
render(width: number): string[] {
const block = "\u2588\u2588\u2588";
const swatch =
theme.fg("success", block) +
" " +
theme.fg("accent", block) +
" " +
theme.fg("warning", block) +
" " +
theme.fg("dim", block) +
" " +
theme.fg("muted", block);
const label = theme.fg("accent", " 🎨 ") + theme.fg("muted", ctx.ui.theme.name) + " " + swatch;
const border = theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
return [border, truncateToWidth(" " + label, width), border];
},
}),
{ placement: "belowEditor" },
);
swatchTimer = setTimeout(() => {
ctx.ui.setWidget("theme-swatch", undefined);
swatchTimer = null;
}, 3000);
}
function getThemeList(ctx: ExtensionContext) {
return ctx.ui.getAllThemes();
}
function findCurrentIndex(ctx: ExtensionContext): number {
const themes = getThemeList(ctx);
const current = ctx.ui.theme.name;
return themes.findIndex((t) => t.name === current);
}
function cycleTheme(ctx: ExtensionContext, direction: 1 | -1) {
if (!ctx.hasUI) return;
const themes = getThemeList(ctx);
if (themes.length === 0) {
ctx.ui.notify("No themes available", "warning");
return;
}
let index = findCurrentIndex(ctx);
if (index === -1) index = 0;
index = (index + direction + themes.length) % themes.length;
const theme = themes[index];
const result = ctx.ui.setTheme(theme.name);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`${theme.name} (${index + 1}/${themes.length})`, "info");
} else {
ctx.ui.notify(`Failed to set theme: ${result.error}`, "error");
}
}
// --- Shortcuts ---
pi.registerShortcut("ctrl+x", {
description: "Cycle theme forward",
handler: async (ctx) => {
currentCtx = ctx;
cycleTheme(ctx, 1);
},
});
pi.registerShortcut("ctrl+q", {
description: "Cycle theme backward",
handler: async (ctx) => {
currentCtx = ctx;
cycleTheme(ctx, -1);
},
});
// --- Command: /theme ---
pi.registerCommand("theme", {
description: "Select a theme: /theme or /theme <name>",
handler: async (args, ctx) => {
currentCtx = ctx;
if (!ctx.hasUI) return;
const themes = getThemeList(ctx);
const arg = args.trim();
if (arg) {
const result = ctx.ui.setTheme(arg);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`Theme: ${arg}`, "info");
} else {
ctx.ui.notify(`Theme not found: ${arg}. Use /theme to see available themes.`, "error");
}
return;
}
const items = themes.map((t) => {
const desc = t.path ? t.path : "built-in";
const active = t.name === ctx.ui.theme.name ? " (active)" : "";
return `${t.name}${active}${desc}`;
});
const selected = await ctx.ui.select("Select Theme", items);
if (!selected) return;
const selectedName = selected.split(/\s/)[0];
const result = ctx.ui.setTheme(selectedName);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`Theme: ${selectedName}`, "info");
}
},
});
// --- Session init ---
pi.on("session_start", async (_event, ctx) => {
currentCtx = ctx;
applyExtensionDefaults(import.meta.url, ctx);
updateStatus(ctx);
});
pi.on("session_shutdown", async () => {
if (swatchTimer) {
clearTimeout(swatchTimer);
swatchTimer = null;
}
});
}

View File

@@ -1,145 +0,0 @@
/**
* themeMap.ts — Per-extension default theme assignments
*
* Themes live in .pi/themes/ and are mapped by extension filename (no extension).
* Each extension calls applyExtensionTheme(import.meta.url, ctx) in its session_start
* hook to automatically load its designated theme on boot.
*
* Available themes (.pi/themes/):
* catppuccin-mocha · cyberpunk · dracula · everforest · gruvbox
* midnight-ocean · nord · ocean-breeze · rose-pine
* synthwave · tokyo-night
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { basename } from "path";
import { fileURLToPath } from "url";
// ── Theme assignments ──────────────────────────────────────────────────────
//
// Key = extension filename without extension (matches extensions/<key>.ts)
// Value = theme name from .pi/themes/<value>.json
//
export const THEME_MAP: Record<string, string> = {
"agent-chain": "midnight-ocean", // deep sequential pipeline
"agent-dashboard": "tokyo-night", // unified monitoring hub
"agent-team": "dracula", // rich orchestration palette
"cross-agent": "ocean-breeze", // cross-boundary, connecting
"damage-control": "gruvbox", // grounded, earthy safety
"minimal": "synthwave", // synthwave by default now!
"observatory": "cyberpunk", // futuristic observation deck
"pi-pi": "rose-pine", // warm creative meta-agent
"pure-focus": "everforest", // calm, distraction-free
"purpose-gate": "tokyo-night", // intentional, sharp focus
"session-replay": "catppuccin-mocha", // soft, reflective history
"subagent-widget": "cyberpunk", // multi-agent futuristic
"system-select": "catppuccin-mocha", // soft selection UI
"theme-cycler": "synthwave", // neon, it's a theme tool
"tilldone": "everforest", // task-focused calm
"tool-counter": "synthwave", // techy metrics
"tool-counter-widget":"synthwave", // same family
};
// ── Helpers ───────────────────────────────────────────────────────────────
/** Derive the extension name (e.g. "minimal") from its import.meta.url. */
function extensionName(fileUrl: string): string {
const filePath = fileUrl.startsWith("file://") ? fileURLToPath(fileUrl) : fileUrl;
return basename(filePath).replace(/\.[^.]+$/, "");
}
// ── Theme ──────────────────────────────────────────────────────────────────
/**
* Apply the mapped theme for an extension on session boot.
*
* @param fileUrl Pass `import.meta.url` from the calling extension file.
* @param ctx The ExtensionContext from the session_start handler.
* @returns true if the theme was applied successfully, false otherwise.
*/
export function applyExtensionTheme(fileUrl: string, ctx: ExtensionContext): boolean {
if (!ctx.hasUI) return false;
const name = extensionName(fileUrl);
// If there are multiple extensions stacked in 'ipi', they each fire session_start
// and try to apply their own mapped theme. The LAST one to fire wins.
// Since system-select is last in the ipi alias array, it was setting 'catppuccin-mocha'.
// We want to skip theme application for all secondary extensions if they are stacked,
// so the primary extension (first in the array) dictates the theme.
const primaryExt = primaryExtensionName();
if (primaryExt && primaryExt !== name) {
return true; // Pretend we succeeded, but don't overwrite the primary theme
}
let themeName = THEME_MAP[name];
if (!themeName) {
themeName = "synthwave";
}
const result = ctx.ui.setTheme(themeName);
if (!result.success && themeName !== "synthwave") {
return ctx.ui.setTheme("synthwave").success;
}
return result.success;
}
// ── Title ──────────────────────────────────────────────────────────────────
/**
* Read process.argv to find the first -e / --extension flag value.
*
* When Pi is launched as:
* pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts
*
* process.argv contains those paths verbatim. Every stacked extension calls
* this and gets the same answer ("subagent-widget"), so all setTitle calls
* are idempotent — no shared state or deduplication needed.
*
* Returns null if no -e flag is present (e.g. plain `pi` with no extensions).
*/
function primaryExtensionName(): string | null {
const argv = process.argv;
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] === "-e" || argv[i] === "--extension") {
return basename(argv[i + 1]).replace(/\.[^.]+$/, "");
}
}
return null;
}
/**
* Set the terminal title to "π - <first-extension-name>" on session boot.
* Reads the title from process.argv so all stacked extensions agree on the
* same value — no coordination or shared state required.
*
* Deferred 150 ms to fire after Pi's own startup title-set.
*/
function applyExtensionTitle(ctx: ExtensionContext): void {
if (!ctx.hasUI) return;
const name = primaryExtensionName();
if (!name) return;
setTimeout(() => ctx.ui.setTitle(`π - ${name}`), 150);
}
// ── Combined default ───────────────────────────────────────────────────────
/**
* Apply both the mapped theme AND the terminal title for an extension.
* Drop-in replacement for applyExtensionTheme — call this in every session_start.
*
* Usage:
* import { applyExtensionDefaults } from "./themeMap.ts";
*
* pi.on("session_start", async (_event, ctx) => {
* applyExtensionDefaults(import.meta.url, ctx);
* // ... rest of handler
* });
*/
export function applyExtensionDefaults(fileUrl: string, ctx: ExtensionContext): void {
applyExtensionTheme(fileUrl, ctx);
applyExtensionTitle(ctx);
}

View File

@@ -1,726 +0,0 @@
/**
* TillDone Extension — Work Till It's Done
*
* A task-driven discipline extension. The agent MUST define what it's going
* to do (via `tilldone add`) before it can use any other tools. On agent
* completion, if tasks remain incomplete, the agent gets nudged to continue
* or mark them done. Play on words: "todo" → "tilldone" (work till done).
*
* Three-state lifecycle: idle → inprogress → done
*
* Each list has a title and description that give the tasks a theme.
* Use `new-list` to start a fresh list. `clear` wipes tasks with user confirm.
*
* UI surfaces:
* - Footer: persistent task list with live progress + list title
* - Widget: prominent "current task" display (the inprogress task)
* - Status: compact summary in the status line
* - /tilldone: interactive overlay with full task details
*
* Usage: pi -e extensions/tilldone.ts
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ──────────────────────────────────────────────────────────────
type TaskStatus = "idle" | "inprogress" | "done";
interface Task {
id: number;
text: string;
status: TaskStatus;
}
interface TillDoneDetails {
action: string;
tasks: Task[];
nextId: number;
listTitle?: string;
listDescription?: string;
error?: string;
}
const TillDoneParams = Type.Object({
action: StringEnum(["new-list", "add", "toggle", "remove", "update", "list", "clear"] as const),
text: Type.Optional(Type.String({ description: "Task text (for add/update), or list title (for new-list)" })),
texts: Type.Optional(Type.Array(Type.String(), { description: "Multiple task texts (for add). Use this to batch-add several tasks at once." })),
description: Type.Optional(Type.String({ description: "List description (for new-list)" })),
id: Type.Optional(Type.Number({ description: "Task ID (for toggle/remove/update)" })),
});
// ── Status helpers ─────────────────────────────────────────────────────
const STATUS_ICON: Record<TaskStatus, string> = { idle: "○", inprogress: "●", done: "✓" };
const NEXT_STATUS: Record<TaskStatus, TaskStatus> = { idle: "inprogress", inprogress: "done", done: "idle" };
const STATUS_LABEL: Record<TaskStatus, string> = { idle: "idle", inprogress: "in progress", done: "done" };
// ── /tilldone overlay component ────────────────────────────────────────
class TillDoneListComponent {
private tasks: Task[];
private title: string | undefined;
private desc: string | undefined;
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(tasks: Task[], title: string | undefined, desc: string | undefined, theme: Theme, onClose: () => void) {
this.tasks = tasks;
this.title = title;
this.desc = desc;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
const lines: string[] = [];
const th = this.theme;
lines.push("");
const heading = this.title
? th.fg("accent", ` ${this.title} `)
: th.fg("accent", " TillDone ");
const headingLen = this.title ? this.title.length + 2 : 10;
lines.push(truncateToWidth(
th.fg("borderMuted", "─".repeat(3)) + heading +
th.fg("borderMuted", "─".repeat(Math.max(0, width - 3 - headingLen))),
width,
));
if (this.desc) {
lines.push(truncateToWidth(` ${th.fg("muted", this.desc)}`, width));
}
lines.push("");
if (this.tasks.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to add some!")}`, width));
} else {
const done = this.tasks.filter((t) => t.status === "done").length;
const active = this.tasks.filter((t) => t.status === "inprogress").length;
const idle = this.tasks.filter((t) => t.status === "idle").length;
lines.push(truncateToWidth(
" " +
th.fg("success", `${done} done`) + th.fg("dim", " ") +
th.fg("accent", `${active} active`) + th.fg("dim", " ") +
th.fg("muted", `${idle} idle`),
width,
));
lines.push("");
for (const task of this.tasks) {
const icon = task.status === "done"
? th.fg("success", STATUS_ICON.done)
: task.status === "inprogress"
? th.fg("accent", STATUS_ICON.inprogress)
: th.fg("dim", STATUS_ICON.idle);
const id = th.fg("accent", `#${task.id}`);
const text = task.status === "done"
? th.fg("dim", task.text)
: task.status === "inprogress"
? th.fg("success", task.text)
: th.fg("muted", task.text);
lines.push(truncateToWidth(` ${icon} ${id} ${text}`, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
// ── Extension entry point ──────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let tasks: Task[] = [];
let nextId = 1;
let listTitle: string | undefined;
let listDescription: string | undefined;
let nudgedThisCycle = false;
// ── Snapshot for details ───────────────────────────────────────────
const makeDetails = (action: string, error?: string): TillDoneDetails => ({
action,
tasks: [...tasks],
nextId,
listTitle,
listDescription,
...(error ? { error } : {}),
});
// ── UI refresh ─────────────────────────────────────────────────────
const refreshWidget = (ctx: ExtensionContext) => {
const current = tasks.find((t) => t.status === "inprogress");
if (!current) {
ctx.ui.setWidget("tilldone-current", undefined);
return;
}
ctx.ui.setWidget("tilldone-current", (_tui, theme) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("dim", s);
container.addChild(new Text("", 0, 0));
container.addChild(new DynamicBorder(borderFn));
const content = new Text("", 1, 0);
container.addChild(content);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const cur = tasks.find((t) => t.status === "inprogress");
if (!cur) return [];
const line =
theme.fg("accent", "● ") +
theme.fg("dim", "WORKING ON ") +
theme.fg("accent", `#${cur.id}`) +
theme.fg("dim", " ") +
theme.fg("success", cur.text);
content.setText(truncateToWidth(line, width - 4));
return container.render(width);
},
invalidate() { container.invalidate(); },
};
}, { placement: "belowEditor" });
};
const refreshFooter = (ctx: ExtensionContext) => {
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
const done = tasks.filter((t) => t.status === "done").length;
const active = tasks.filter((t) => t.status === "inprogress").length;
const idle = tasks.filter((t) => t.status === "idle").length;
const total = tasks.length;
// ── Line 1: list title + progress (left), counts (right) ──
const titleDisplay = listTitle
? theme.fg("accent", ` ${listTitle} `)
: theme.fg("dim", " TillDone ");
const l1Left = total === 0
? titleDisplay + theme.fg("muted", "no tasks")
: titleDisplay +
theme.fg("warning", "[") +
theme.fg("success", `${done}`) +
theme.fg("dim", "/") +
theme.fg("success", `${total}`) +
theme.fg("warning", "]");
const l1Right = total === 0
? ""
: theme.fg("dim", STATUS_ICON.idle + " ") + theme.fg("muted", `${idle}`) +
theme.fg("dim", " ") +
theme.fg("accent", STATUS_ICON.inprogress + " ") + theme.fg("accent", `${active}`) +
theme.fg("dim", " ") +
theme.fg("success", STATUS_ICON.done + " ") + theme.fg("success", `${done}`) +
theme.fg("dim", " ");
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
if (total === 0) return [line1];
// ── Rows: inprogress first, then most recent done, max 5 ──
const activeTasks = tasks.filter((t) => t.status === "inprogress");
const doneTasks = tasks.filter((t) => t.status === "done").reverse();
const visible = [...activeTasks, ...doneTasks].slice(0, 5);
const remaining = total - visible.length;
const rows = visible.map((t) => {
const icon = t.status === "done"
? theme.fg("success", STATUS_ICON.done)
: theme.fg("accent", STATUS_ICON.inprogress);
const text = t.status === "done"
? theme.fg("dim", t.text)
: theme.fg("success", t.text);
return truncateToWidth(` ${icon} ${text}`, width, "");
});
if (remaining > 0) {
rows.push(truncateToWidth(
` ${theme.fg("dim", ` +${remaining} more`)}`,
width, "",
));
}
return [line1, ...rows];
},
};
});
};
const refreshUI = (ctx: ExtensionContext) => {
if (tasks.length === 0) {
ctx.ui.setStatus("📋 TillDone: no tasks", "tilldone");
} else {
const remaining = tasks.filter((t) => t.status !== "done").length;
const label = listTitle ? `📋 ${listTitle}` : "📋 TillDone";
ctx.ui.setStatus(`${label}: ${tasks.length} tasks (${remaining} remaining)`, "tilldone");
}
refreshWidget(ctx);
refreshFooter(ctx);
};
// ── State reconstruction from session ──────────────────────────────
const reconstructState = (ctx: ExtensionContext) => {
tasks = [];
nextId = 1;
listTitle = undefined;
listDescription = undefined;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult" || msg.toolName !== "tilldone") continue;
const details = msg.details as TillDoneDetails | undefined;
if (details) {
tasks = details.tasks;
nextId = details.nextId;
listTitle = details.listTitle;
listDescription = details.listDescription;
}
}
refreshUI(ctx);
};
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
reconstructState(ctx);
});
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// ── Blocking gate ──────────────────────────────────────────────────
pi.on("tool_call", async (event, _ctx) => {
if (event.toolName === "tilldone") return { block: false };
const pending = tasks.filter((t) => t.status !== "done");
const active = tasks.filter((t) => t.status === "inprogress");
if (tasks.length === 0) {
return {
block: true,
reason: "🚫 No TillDone tasks defined. You MUST use `tilldone new-list` or `tilldone add` to define your tasks before using any other tools. Plan your work first!",
};
}
if (pending.length === 0) {
return {
block: true,
reason: "🚫 All TillDone tasks are done. You MUST use `tilldone add` for new tasks or `tilldone new-list` to start a fresh list before using any other tools.",
};
}
if (active.length === 0) {
return {
block: true,
reason: "🚫 No task is in progress. You MUST use `tilldone toggle` to mark a task as inprogress before doing any work.",
};
}
return { block: false };
});
// ── Auto-nudge on agent_end ────────────────────────────────────────
pi.on("agent_end", async (_event, _ctx) => {
const incomplete = tasks.filter((t) => t.status !== "done");
if (incomplete.length === 0 || nudgedThisCycle) return;
nudgedThisCycle = true;
const taskList = incomplete
.map((t) => ` ${STATUS_ICON[t.status]} #${t.id} [${STATUS_LABEL[t.status]}]: ${t.text}`)
.join("\n");
pi.sendMessage(
{
customType: "tilldone-nudge",
content: `⚠️ You still have ${incomplete.length} incomplete task(s):\n\n${taskList}\n\nEither continue working on them or mark them done with \`tilldone toggle\`. Don't stop until it's done!`,
display: true,
},
{ triggerTurn: true },
);
});
pi.on("input", async () => {
nudgedThisCycle = false;
return { action: "continue" as const };
});
// ── Register tilldone tool ─────────────────────────────────────────
pi.registerTool({
name: "tilldone",
label: "TillDone",
description:
"Manage your task list. You MUST add tasks before using any other tools. " +
"Actions: new-list (text=title, description), add (text or texts[] for batch), toggle (id) — cycles idle→inprogress→done, remove (id), update (id + text), list, clear. " +
"Always toggle a task to inprogress before starting work on it, and to done when finished. " +
"Use new-list to start a themed list with a title and description. " +
"IMPORTANT: If the user's new request does not fit the current list's theme, use clear to wipe the slate and new-list to start fresh.",
parameters: TillDoneParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
switch (params.action) {
case "new-list": {
if (!params.text) {
return {
content: [{ type: "text" as const, text: "Error: text (title) required for new-list" }],
details: makeDetails("new-list", "text required"),
};
}
// If a list already exists, confirm before replacing
if (tasks.length > 0 || listTitle) {
const confirmed = await ctx.ui.confirm(
"Start a new list?",
`This will replace${listTitle ? ` "${listTitle}"` : " the current list"} (${tasks.length} task(s)). Continue?`,
{ timeout: 30000 },
);
if (!confirmed) {
return {
content: [{ type: "text" as const, text: "New list cancelled by user." }],
details: makeDetails("new-list", "cancelled"),
};
}
}
tasks = [];
nextId = 1;
listTitle = params.text;
listDescription = params.description || undefined;
const result = {
content: [{
type: "text" as const,
text: `New list: "${listTitle}"${listDescription ? `${listDescription}` : ""}`,
}],
details: makeDetails("new-list"),
};
refreshUI(ctx);
return result;
}
case "list": {
const header = listTitle ? `${listTitle}:` : "";
const result = {
content: [{
type: "text" as const,
text: tasks.length
? (header ? header + "\n" : "") +
tasks.map((t) => `[${STATUS_ICON[t.status]}] #${t.id} (${t.status}): ${t.text}`).join("\n")
: "No tasks defined yet.",
}],
details: makeDetails("list"),
};
refreshUI(ctx);
return result;
}
case "add": {
const items = params.texts?.length ? params.texts : params.text ? [params.text] : [];
if (items.length === 0) {
return {
content: [{ type: "text" as const, text: "Error: text or texts required for add" }],
details: makeDetails("add", "text required"),
};
}
const added: Task[] = [];
for (const item of items) {
const t: Task = { id: nextId++, text: item, status: "idle" };
tasks.push(t);
added.push(t);
}
const msg = added.length === 1
? `Added task #${added[0].id}: ${added[0].text}`
: `Added ${added.length} tasks: ${added.map((t) => `#${t.id}`).join(", ")}`;
const result = {
content: [{ type: "text" as const, text: msg }],
details: makeDetails("add"),
};
refreshUI(ctx);
return result;
}
case "toggle": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for toggle" }],
details: makeDetails("toggle", "id required"),
};
}
const task = tasks.find((t) => t.id === params.id);
if (!task) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("toggle", `#${params.id} not found`),
};
}
const prev = task.status;
task.status = NEXT_STATUS[task.status];
// Enforce single inprogress — demote any other active task
const demoted: Task[] = [];
if (task.status === "inprogress") {
for (const t of tasks) {
if (t.id !== task.id && t.status === "inprogress") {
t.status = "idle";
demoted.push(t);
}
}
}
let msg = `Task #${task.id}: ${prev}${task.status}`;
if (demoted.length > 0) {
msg += `\n(Auto-paused ${demoted.map((t) => `#${t.id}`).join(", ")} → idle. Only one task can be in progress at a time.)`;
}
const result = {
content: [{
type: "text" as const,
text: msg,
}],
details: makeDetails("toggle"),
};
refreshUI(ctx);
return result;
}
case "remove": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for remove" }],
details: makeDetails("remove", "id required"),
};
}
const idx = tasks.findIndex((t) => t.id === params.id);
if (idx === -1) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("remove", `#${params.id} not found`),
};
}
const removed = tasks.splice(idx, 1)[0];
const result = {
content: [{ type: "text" as const, text: `Removed task #${removed.id}: ${removed.text}` }],
details: makeDetails("remove"),
};
refreshUI(ctx);
return result;
}
case "update": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for update" }],
details: makeDetails("update", "id required"),
};
}
if (!params.text) {
return {
content: [{ type: "text" as const, text: "Error: text required for update" }],
details: makeDetails("update", "text required"),
};
}
const toUpdate = tasks.find((t) => t.id === params.id);
if (!toUpdate) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("update", `#${params.id} not found`),
};
}
const oldText = toUpdate.text;
toUpdate.text = params.text;
const result = {
content: [{ type: "text" as const, text: `Updated #${toUpdate.id}: "${oldText}" → "${toUpdate.text}"` }],
details: makeDetails("update"),
};
refreshUI(ctx);
return result;
}
case "clear": {
if (tasks.length > 0) {
const confirmed = await ctx.ui.confirm(
"Clear TillDone list?",
`This will remove all ${tasks.length} task(s)${listTitle ? ` from "${listTitle}"` : ""}. Continue?`,
{ timeout: 30000 },
);
if (!confirmed) {
return {
content: [{ type: "text" as const, text: "Clear cancelled by user." }],
details: makeDetails("clear", "cancelled"),
};
}
}
const count = tasks.length;
tasks = [];
nextId = 1;
listTitle = undefined;
listDescription = undefined;
const result = {
content: [{ type: "text" as const, text: `Cleared ${count} task(s)` }],
details: makeDetails("clear"),
};
refreshUI(ctx);
return result;
}
default:
return {
content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
details: makeDetails("list", `unknown action: ${params.action}`),
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("tilldone ")) + theme.fg("muted", args.action);
if (args.texts?.length) text += ` ${theme.fg("dim", `${args.texts.length} tasks`)}`;
else if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.description) text += ` ${theme.fg("dim", `${args.description}`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as TillDoneDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
}
const taskList = details.tasks;
switch (details.action) {
case "new-list": {
let msg = theme.fg("success", "✓ New list ") + theme.fg("accent", `"${details.listTitle}"`);
if (details.listDescription) {
msg += theme.fg("dim", `${details.listDescription}`);
}
return new Text(msg, 0, 0);
}
case "list": {
if (taskList.length === 0) return new Text(theme.fg("dim", "No tasks"), 0, 0);
let listText = "";
if (details.listTitle) {
listText += theme.fg("accent", details.listTitle) + theme.fg("dim", " ");
}
listText += theme.fg("muted", `${taskList.length} task(s):`);
const display = expanded ? taskList : taskList.slice(0, 5);
for (const t of display) {
const icon = t.status === "done"
? theme.fg("success", STATUS_ICON.done)
: t.status === "inprogress"
? theme.fg("accent", STATUS_ICON.inprogress)
: theme.fg("dim", STATUS_ICON.idle);
const itemText = t.status === "done"
? theme.fg("dim", t.text)
: t.status === "inprogress"
? theme.fg("success", t.text)
: theme.fg("muted", t.text);
listText += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
}
if (!expanded && taskList.length > 5) {
listText += `\n${theme.fg("dim", `... ${taskList.length - 5} more`)}`;
}
return new Text(listText, 0, 0);
}
case "add": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "toggle": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("accent", "⟳ ") + theme.fg("muted", msg), 0, 0);
}
case "remove": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("warning", "✕ ") + theme.fg("muted", msg), 0, 0);
}
case "update": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "clear":
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all tasks"), 0, 0);
default:
return new Text(theme.fg("dim", "done"), 0, 0);
}
},
});
// ── /tilldone command ──────────────────────────────────────────────
pi.registerCommand("tilldone", {
description: "Show all TillDone tasks on the current branch",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/tilldone requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new TillDoneListComponent(tasks, listTitle, listDescription, theme, () => done());
});
},
});
}

View File

@@ -1,68 +0,0 @@
/**
* Tool Counter Widget — Tool call counts in a widget above the editor
*
* Shows a persistent, live-updating widget with per-tool background colors.
* Format: Tools (N): [Bash 3] [Read 7] [Write 2]
*
* Usage: pi -e extensions/tool-counter-widget.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Box, Text } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
const palette = [
[12, 40, 80], // deep navy
[50, 20, 70], // dark purple
[10, 55, 45], // dark teal
[70, 30, 10], // dark rust
[55, 15, 40], // dark plum
[15, 50, 65], // dark ocean
[45, 45, 15], // dark olive
[65, 18, 25], // dark wine
];
function bg(rgb: number[], s: string): string {
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[49m`;
}
export default function (pi: ExtensionAPI) {
const counts: Record<string, number> = {};
const toolColors: Record<string, number[]> = {};
let total = 0;
let colorIdx = 0;
pi.on("tool_execution_end", async (event) => {
if (!(event.toolName in toolColors)) {
toolColors[event.toolName] = palette[colorIdx % palette.length];
colorIdx++;
}
counts[event.toolName] = (counts[event.toolName] || 0) + 1;
total++;
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setWidget("tool-counter", (_tui, theme) => {
const text = new Text("", 1, 1);
return {
render(width: number): string[] {
const entries = Object.entries(counts);
const parts = entries.map(([name, count]) => {
const rgb = toolColors[name];
return bg(rgb, `\x1b[38;2;220;220;220m ${name} ${count} \x1b[39m`);
});
text.setText(
theme.fg("accent", `Tools (${total}):`) +
(entries.length > 0 ? " " + parts.join(" ") : "")
);
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
});
}

View File

@@ -1,102 +0,0 @@
/**
* Tool Counter — Rich two-line custom footer
*
* Line 1: model + context meter on left, tokens in/out + cost on right
* Line 2: cwd (branch) on left, tool call tally on right
*
* Demonstrates: setFooter, footerData.getGitBranch(), onBranchChange(),
* session branch traversal for token/cost accumulation.
*
* Usage: pi -e extensions/tool-counter.ts
*/
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { basename } from "node:path";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
const counts: Record<string, number> = {};
pi.on("tool_execution_end", async (event) => {
counts[event.toolName] = (counts[event.toolName] || 0) + 1;
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
// --- Line 1: cwd + branch (left), tokens + cost (right) ---
let tokIn = 0;
let tokOut = 0;
let cost = 0;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "assistant") {
const m = entry.message as AssistantMessage;
tokIn += m.usage.input;
tokOut += m.usage.output;
cost += m.usage.cost.total;
}
}
const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
const dir = basename(ctx.cwd);
const branch = footerData.getGitBranch();
// --- Line 1: model + context meter (left), tokens + cost (right) ---
const usage = ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10) || 1;
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const model = ctx.model?.id || "no-model";
const l1Left =
theme.fg("dim", ` ${model} `) +
theme.fg("warning", "[") +
theme.fg("success", "#".repeat(filled)) +
theme.fg("dim", "-".repeat(10 - filled)) +
theme.fg("warning", "]") +
theme.fg("dim", " ") +
theme.fg("accent", `${Math.round(pct)}%`);
const l1Right =
theme.fg("success", `${fmt(tokIn)}`) +
theme.fg("dim", " in ") +
theme.fg("accent", `${fmt(tokOut)}`) +
theme.fg("dim", " out ") +
theme.fg("warning", `$${cost.toFixed(4)}`) +
theme.fg("dim", " ");
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
// --- Line 2: cwd + branch (left), tool tally (right) ---
const l2Left =
theme.fg("dim", ` ${dir}`) +
(branch
? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")")
: "");
const entries = Object.entries(counts);
const l2Right = entries.length === 0
? theme.fg("dim", "waiting for tools ")
: entries.map(
([name, count]) =>
theme.fg("accent", name) + theme.fg("dim", " ") + theme.fg("success", `${count}`)
).join(theme.fg("warning", " | ")) + theme.fg("dim", " ");
const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right)));
const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, "");
return [line1, line2];
},
};
});
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
<path fill="#fff" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<!-- i dot -->
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 473 B

114
justfile
View File

@@ -1,114 +0,0 @@
set dotenv-load := true
set shell := ["pwsh", "-NoProfile", "-Command"]
default:
@just --list
# g1
# 1. default pi
pi:
pi
# 2. Pure focus pi: strip footer and status line entirely
ext-pure-focus:
pi -e extensions/pure-focus.ts
# 3. Minimal pi: model name + 10-block context meter
ext-minimal:
pi -e extensions/minimal.ts -e extensions/theme-cycler.ts
# 4. Cross-agent pi: load commands from .claude/, .gemini/, .codex/ dirs
ext-cross-agent:
pi -e extensions/cross-agent.ts -e extensions/minimal.ts
# 5. Purpose gate pi: declare intent before working, persistent widget, focus the system prompt on the ONE PURPOSE for this agent
ext-purpose-gate:
pi -e extensions/purpose-gate.ts -e extensions/minimal.ts
# 6. Customized footer pi: Tool counter, model, branch, cwd, cost, etc.
ext-tool-counter:
pi -e extensions/tool-counter.ts
# 7. Tool counter widget: tool call counts in a below-editor widget
ext-tool-counter-widget:
pi -e extensions/tool-counter-widget.ts -e extensions/minimal.ts
# 8. Subagent widget: /sub <task> with live streaming progress
ext-subagent-widget:
pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts -e extensions/theme-cycler.ts
# 9. TillDone: task-driven discipline — define tasks before working
ext-tilldone:
pi -e extensions/tilldone.ts -e extensions/theme-cycler.ts
#g2
# 10. Agent team: dispatcher orchestrator with team select and grid dashboard
ext-agent-team:
pi -e extensions/agent-team.ts -e extensions/theme-cycler.ts
# 11. System select: /system to pick an agent persona as system prompt
ext-system-select:
pi -e extensions/system-select.ts -e extensions/minimal.ts -e extensions/theme-cycler.ts
# 12. Launch with Damage-Control safety auditing
ext-damage-control:
pi -e extensions/damage-control.ts -e extensions/minimal.ts -e extensions/theme-cycler.ts
# 13. Agent chain: sequential pipeline orchestrator
ext-agent-chain:
pi -e extensions/agent-chain.ts -e extensions/theme-cycler.ts
#g3
# 14. Pi Pi: meta-agent that builds Pi agents with parallel expert research
ext-pi-pi:
pi -e extensions/pi-pi.ts -e extensions/theme-cycler.ts
#ext
# 15. Observatory: comprehensive observability dashboard with live widget, overlay, and export
ext-observatory:
pi -e extensions/observatory.ts -e extensions/theme-cycler.ts
# 16. Agent Dashboard: unified observability across team, subagent, and chain interfaces
ext-agent-dashboard:
pi -e extensions/agent-dashboard.ts -e extensions/theme-cycler.ts
# 17. Session Replay: scrollable timeline overlay of session history (legit)
ext-session-replay:
pi -e extensions/session-replay.ts -e extensions/minimal.ts
# 18. Theme cycler: Ctrl+X forward, Ctrl+Q backward, /theme picker
ext-theme-cycler:
pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
# utils
# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter
open +exts:
#!/usr/bin/env pwsh
$args_str = ""
foreach ($ext in "{{exts}}".Split(" ")) { $args_str += " -e extensions/$ext.ts" }
$cmd = "cd '{{justfile_directory()}}'; pi$args_str"
Start-Process wt -ArgumentList "pwsh", "-NoExit", "-Command", $cmd
# Open every extension in its own terminal window
all:
just open pi
just open pure-focus
just open minimal theme-cycler
just open cross-agent minimal
just open purpose-gate minimal
just open tool-counter
just open tool-counter-widget minimal
just open subagent-widget pure-focus theme-cycler
just open tilldone theme-cycler
just open agent-team theme-cycler
just open system-select minimal theme-cycler
just open damage-control minimal theme-cycler
just open agent-chain theme-cycler
just open pi-pi theme-cycler
just open observatory theme-cycler
just open agent-dashboard theme-cycler

View File

@@ -1,12 +0,0 @@
{
"name": "pi-vs-cc",
"private": true,
"type": "module",
"description": "Pi Coding Agent extension playground",
"dependencies": {
"yaml": "^2.8.0"
},
"devDependencies": {
"@playwright/cli": "^0.1.1"
}
}

View File

@@ -1,72 +0,0 @@
# Specification: Agent Forge (Evolutionary Tooling)
## 1. Overview
**Agent Forge** is an evolutionary extension for the Pi Coding Agent. It enables the agent to expand its own capabilities by dynamically generating, validating, and loading new TypeScript tools on demand. Instead of a static set of capabilities, Agent Forge turns the agent into a meta-developer that builds its own infrastructure.
## 2. Core Architecture
### 2.1 The Toolbox
All evolved tools are stored in the `extensions/` directory with a specific naming pattern:
- `extensions/forge-<name>.ts`: The executable TypeScript logic.
- `extensions/forge-<name>.json`: Metadata, including the tool's description and TypeBox parameters schema.
- `extensions/forge-registry.json`: A central manifest for fast tool discovery during the `before_agent_start` hook.
### 2.2 The Proxy Model
Unlike `agent-team` which spawns new processes, Agent Forge uses a **Hybrid Proxy Model**:
1. **Dynamic Loading**: Uses `jiti` (Pi's internal runtime) to load forged tools into the existing process.
2. **Context Sharing**: Forged tools have direct access to the `ExtensionAPI`, allowing them to interact with the UI, notify the user, and use the existing toolset (read/write/bash).
3. **Zero Overhead**: Execution is instantaneous as it happens within the same Node.js/Bun runtime.
## 3. Core Tools
### 3.1 `forge_tool`
- **Purpose**: Generates a new tool or updates an existing one.
- **Inputs**: `name`, `description`, `parametersSchema`, and `logic` (the TypeScript body).
- **Process**:
1. Wraps `logic` in a standard tool template.
2. Writes `.ts` and `.json` files to `extensions/`.
3. **Pre-flight Check**: Attempts to load the tool via `jiti`. If it fails (syntax error), it reports the error to the agent for "Self-Healing".
4. Updates `forge-registry.json`.
### 3.2 `use_forge_tool`
- **Purpose**: Executes a previously forged tool.
- **Process**:
1. Resolves the tool from the registry.
2. Dynamically imports the `.ts` file.
3. Passes arguments to the tool's `execute` function.
4. Handles runtime errors gracefully, offering to "debug" the tool if it crashes.
### 3.3 `list_forge`
- **Purpose**: Lists all available evolved tools and their descriptions.
## 4. Safety & Self-Healing
- **Sandboxing**: Forged tools are restricted to a "Core Library" of imports (fs, path, child_process, typebox).
- **Versioning**: Each `forge_tool` call creates a `.bak` of the previous version.
- **Self-Healing**: If `use_forge_tool` or `forge_tool`'s pre-flight check fails, the agent is provided with the stack trace and the source code to perform an immediate fix.
## 5. UI Integration
- **Forge Widget**: A dedicated dashboard element showing:
- **Evolved Tools**: Count of active tools.
- **Last Action**: "Forged 'sql-explorer' 2m ago" or "Executing 'log-parser'...".
- **Health**: Indicator of any tools currently in a "broken" state.
- **Status Bar**: Displays the "Forge Tier" (based on number of successful tools).
## 6. Template Structure
Every forged tool follows this mandatory structure:
```typescript
import { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export const metadata = {
name: "custom_tool",
description: "...",
parameters: Type.Object({ ... })
};
export async function execute(params: any, pi: ExtensionAPI, ctx: any) {
// Logic goes here
}
```
## 7. Integration with Agent-Team
Agent Forge can act as a "specialist" within an `agent-team`. The "Engineer" agent in a team can use Agent Forge to build tools for the "Analyst" or "Builder" agents, creating a collaborative ecosystem of meta-programming.

View File

@@ -1,64 +0,0 @@
# Specification: The Chronicle (agent-workflow)
## 1. Overview
**The Chronicle** is a temporal orchestration extension for the Pi Coding Agent. It enables long-running, state-aware workflows that span multiple sessions and personas. Unlike traditional linear agents, The Chronicle manages a formal **State Machine** where each stage of a project is handled by a specialized agent persona, with the extension acting as a persistent "State Supervisor."
## 2. Core Architecture
### 2.1 The Supervisor Model
The Chronicle operates as a non-working supervisor that delegates tasks to worker agents.
- **The Ledger**: A persistent JSON file (`.pi/chronicle/sessions/<uuid>.json`) that tracks the project state, file snapshots, and transition history.
- **State Isolation**: Each state is executed by a fresh Pi sub-agent process with a specialized system prompt, preventing "persona leakage" and ensuring clean tool contexts.
- **Context Handover**: When transitioning, the Supervisor extracts a "Snapshot" (modified files, key discoveries, pending tasks) and injects it into the next agent's starting context.
### 2.2 Workflow Definition
Workflows are defined in JSON templates:
```json
{
"name": "Feature Implementation",
"states": {
"planning": {
"persona": "Software Architect",
"next": ["implementation"],
"requires_approval": true
},
"implementation": {
"persona": "Senior Engineer",
"next": ["verification", "planning"]
}
}
}
```
## 3. Key Mechanisms
### 3.1 Explicit Transitions
To ensure reliability, transitions are **explicit**. The agent must call a tool to signal completion:
- `workflow_transition(target_state, summary)`: Finalizes the current state, saves the snapshot, and triggers the supervisor to spawn the next agent.
- `workflow_update_snapshot(data)`: Allows agents to "checkpoint" critical findings (e.g., "The API port is 8081, not 8080") that must persist through the entire workflow.
### 3.2 Temporal Persistence
- **Checkpointing**: Every tool call and state change is logged to the Ledger.
- **Recovery**: If a session is interrupted (e.g., power loss, manual exit), the extension can resume exactly where it left off by reading the Ledger and re-priming the sub-agent.
### 3.3 TUI Integration (The Timeline)
A dedicated widget displays the project's journey:
- **Breadcrumbs**: `Planning [✓] -> Implementation [●] -> Verification [ ]`.
- **Metrics**: Displays cumulative token usage and time elapsed per state.
- **Diff View**: Shows which files have been modified since the start of the current state.
## 4. Operational Guardrails
### 4.1 Anti-Looping
If a workflow transitions between the same states more than 3 times (e.g., Planning -> Implementation -> Planning -> Implementation), the Supervisor forces a transition to a `human_intervention` state and blocks further automated moves.
### 4.2 Resource Budgeting
The Supervisor tracks the total cost and token consumption across all sub-agents. It can be configured with hard limits to prevent runaway costs in long-running workflows.
### 4.3 Cleanup
Each state can define a cleanup routine that the Supervisor executes (e.g., killing background processes) before the next agent is spawned.
## 5. Integration
The Chronicle integrates with:
- **agent-team**: To fetch specialized personas for specific states.
- **damage-control**: To enforce safety rules across all worker sub-agents spawned by the Supervisor.

View File

@@ -1,44 +0,0 @@
# Specification: Damage-Control Extension
## 1. Overview
**Damage-Control** is a safety and observability extension for the Pi Coding Agent. It enforces security patterns and "Rules of Engagement" by auditing tool calls in real-time. It intercepts potentially dangerous operations and enforces path-based access controls.
## 2. Core Architecture
- **Rule Engine**: Loads `.pi/damage-control-rules.yaml` on `session_start`. If missing, it defaults to an empty rule set.
- **Interception Hook**: Uses `pi.on("tool_call", handler)` to evaluate every tool call before execution.
- **Path Resolver**: Utility to expand tildes (`~`) and resolve relative paths against the current working directory (`cwd`) for accurate matching.
## 3. Tool Interception Logic
The extension uses `isToolCallEventType(toolName, event)` for type-safe narrowing of events.
### A. Bash Tool (`bash`)
- **Input Field**: `event.input.command`.
- **Destructive Patterns**: Match `bashToolPatterns` regex against the raw command string.
- **Path Matching**: Best-effort heuristic. Match `zeroAccessPaths`, `readOnlyPaths`, and `noDeletePaths` as substrings/regex patterns within the command string.
- **Modification Detection**: Block any bash command referencing `readOnlyPaths` patterns to prevent redirects (`>`), in-place edits (`sed -i`), or moves/deletes.
### B. File Tools (`read`, `write`, `edit`, `grep`, `find`, `ls`)
- **Input Field**: `event.input.path`.
- **Default Path**: For `grep`, `find`, and `ls`, if `path` is undefined, treat it as `ctx.cwd` for matching.
- **Access Control**:
- **Zero Access**: Block if path matches any `zeroAccessPaths` pattern.
- **Grep Glob**: Check the `glob` field of `grep` (`event.input.glob`) against `zeroAccessPaths`.
- **Read Only**: Block `write` or `edit` calls if path matches `readOnlyPaths`.
- **No Delete**: Block `bash` calls involving `rm` or similar on `noDeletePaths`.
## 4. Intervention & UI
- **Status Indicator**: Use `ctx.ui.setStatus()` to show an indicator of active safety rules (e.g., "🛡️ Damage-Control Active: 142 Rules").
- **Violation Feedback**: When a violation is blocked or confirmed, update the status temporarily to show the last event (e.g., "⚠️ Last Violation: git reset --hard").
- **Blocking**: Return `{ block: true, reason: "Security Policy Violation: [Reason]" }`.
- **User Confirmation (`ask: true`)**:
- For rules with `ask: true`, the handler must `await ctx.ui.confirm(title, message, { timeout: 30000 })`.
- Return `{ block: !confirmed, reason: "User denied execution" }`.
- **Notifications**: Use `ctx.ui.notify()` to alert the user when a rule is triggered.
## 5. Logging & Persistence
- Every interception (block or confirm) is logged using `pi.appendEntry("damage-control-log", { tool, input, rule, action })`. This ensures the security audit is part of the permanent session history.
## 6. Implementation Notes
- **Path Resolution**: Must match against both raw input (e.g., `src/main.ts`) and absolute resolved paths. Handle `ctx.cwd` fallback for optional paths.
- **Tilde Expansion**: Manually expand `~` to `process.env.HOME` or `os.homedir()`.
- **Graceful Fallback**: If YAML parsing fails, notify the user and continue with no active rules rather than crashing the extension.

View File

@@ -1,138 +0,0 @@
# Pi Pi — Meta Agent Spec
## Purpose
A Pi extension that builds Pi agents. The "Pi Pi" agent is a meta-agent — it knows how to create extensions, themes, skills, settings, prompt templates, and TUI components by querying a team of domain-specific research agents in parallel.
## Architecture
```
User Request: "Build me a Pi agent that does X"
┌──────────────────────────────────┐
│ Primary Agent ("Pi Pi") │
│ Tools: read,write,edit,bash, │
│ grep,find,ls, │
│ query_expert │
│ Role: WRITER — gathers info │
│ from experts, then builds │
└──────┬───────────────────────────┘
│ query_expert (parallel)
├──────────────────────────┐──────────────────────┐──────────────────────┐──────────────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ext-expert │ │ theme-expert │ │ skill-expert │ │ config-expert│ │ tui-expert │
│ Extensions │ │ Themes │ │ Skills │ │ Settings │ │ TUI/UI │
│ Tools, cmds │ │ JSON format │ │ SKILL.md │ │ Providers │ │ Components │
│ Events, API │ │ Color tokens │ │ Frontmatter │ │ Models │ │ Rendering │
│ │ │ Hot reload │ │ Directories │ │ Packages │ │ Keyboard │
│ read-only │ │ read-only │ │ read-only │ │ read-only │ │ read-only │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
## Flow
1. User asks the primary Pi Pi agent to build something
2. Primary agent identifies which domains are relevant
3. Primary dispatches `query_expert` calls in PARALLEL to all relevant experts
4. Each expert:
a. Uses `/skill:firecrawl` to scrape fresh Pi documentation for their domain
b. Searches the local codebase for existing patterns and examples
c. Returns structured research findings
5. Primary agent receives ALL expert responses
6. Primary agent synthesizes the information and WRITES the actual files
## Expert Agents
### ext-expert (Extensions)
- **Domain**: Pi extensions — custom tools, events, commands, shortcuts, flags, state management, custom rendering, overriding tools
- **Doc URL**: `https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md`
- **Tools**: read,grep,find,ls,bash
- **First action**: Fetch fresh extensions.md via firecrawl
### theme-expert (Themes)
- **Domain**: Pi themes — JSON format, 51 color tokens, vars, hex/256-color values, hot reload
- **Doc URL**: `https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md`
- **Tools**: read,grep,find,ls,bash
- **First action**: Fetch fresh themes.md via firecrawl
### skill-expert (Skills)
- **Domain**: Pi skills — SKILL.md format, frontmatter, directories, validation, /skill:name commands
- **Doc URL**: `https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md`
- **Tools**: read,grep,find,ls,bash
- **First action**: Fetch fresh skills.md via firecrawl
### config-expert (Settings & Providers)
- **Domain**: Pi settings, providers, models, packages, keybindings — settings.json, models.json, packages, enabledModels
- **Doc URLs**: settings.md, providers.md, models.md, packages.md, keybindings.md
- **Tools**: read,grep,find,ls,bash
- **First action**: Fetch fresh settings.md + providers.md via firecrawl
### tui-expert (TUI Components)
- **Domain**: Pi TUI — Component interface, Text, Box, Container, Markdown, Image, keyboard input, custom components, overlays, theming, SelectList, SettingsList, BorderedLoader, widgets, footers, editors
- **Doc URL**: `https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md`
- **Tools**: read,grep,find,ls,bash
- **First action**: Fetch fresh tui.md via firecrawl
## Extension Structure
File: `extensions/pi-pi.ts`
### Differences from agent-team.ts
| Feature | agent-team | pi-pi |
|---------|-----------|-------|
| Primary tools | dispatch_agent ONLY | read,write,edit,bash,grep,find,ls + query_expert |
| Subagent tools | varies per agent | read,grep,find,ls,bash (read-only + bash for firecrawl) |
| Dispatch model | Sequential | Parallel (LLM calls query_expert N times) |
| Subagent sessions | Persistent | Ephemeral (--no-session) |
| System prompt | Generic dispatcher | Specialized meta-agent builder |
| First prompt | None | Each expert fetches fresh docs on first query |
### Tool: query_expert
```typescript
pi.registerTool({
name: "query_expert",
label: "Query Expert",
description: "Query a domain expert for Pi documentation and patterns. Experts research in parallel. Use multiple query_expert calls in one response for parallel research.",
parameters: Type.Object({
expert: Type.String({ description: "Expert name: ext-expert, theme-expert, skill-expert, config-expert, tui-expert" }),
question: Type.String({ description: "What to research — be specific about what you need to build" }),
}),
})
```
### Widget
Grid of expert cards showing:
- Expert name and status (idle/researching/done/error)
- Current question being researched
- Elapsed time
### Justfile Entry
```just
ext-pi-pi:
pi -e extensions/pi-pi.ts
```
## Agent Definition Files
Located in `.pi/agents/`:
- `ext-expert.md`
- `theme-expert.md`
- `skill-expert.md`
- `config-expert.md`
- `tui-expert.md`
Teams entry in `.pi/agents/teams.yaml`:
```yaml
pi-pi:
- ext-expert
- theme-expert
- skill-expert
- config-expert
- tui-expert
```