pi-code 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,8 @@ import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
17
17
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
18
18
  import { Key } from '@earendil-works/pi-tui'
19
19
  import { Type } from 'typebox'
20
+
21
+ import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
20
22
  import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
21
23
 
22
24
  // Tools
@@ -70,6 +72,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
70
72
  }
71
73
  }
72
74
 
75
+ /** Hooks report Claude's permission_mode from this bus state. */
76
+ function publishPlanState(): void {
77
+ pi.events.emit(PLAN_MODE_CHANNEL, { active: planModeEnabled })
78
+ }
79
+
73
80
  pi.registerFlag('plan', {
74
81
  description: 'Start in plan mode (read-only exploration)',
75
82
  type: 'boolean',
@@ -116,6 +123,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
116
123
  }
117
124
  // Persist the toggle so a resume does not restore a state the user left.
118
125
  persistState()
126
+ publishPlanState()
119
127
  updateStatus(ctx)
120
128
  }
121
129
 
@@ -158,6 +166,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
158
166
  executionMode = todoItems.length > 0
159
167
  planFromTool = false
160
168
  restoreTools()
169
+ publishPlanState()
161
170
  updateStatus(ctx)
162
171
 
163
172
  // Persist before the turn: a crash before the first turn_end must resume into
@@ -377,6 +386,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
377
386
  todoItems = planModeEntry.data.todos ?? todoItems
378
387
  executionMode = planModeEntry.data.executing ?? executionMode
379
388
  }
389
+ publishPlanState()
380
390
 
381
391
  // On resume: re-scan messages after the last "plan-mode-execute" to rebuild
382
392
  // completion state without picking up [DONE:n] from previous plans
@@ -1,16 +1,36 @@
1
1
  /**
2
2
  * Status Line Extension
3
3
  *
4
- * Adds a Claude Code style status segment to pi's footer: turn state plus
5
- * running session cost. Cost is summed from per-message usage on the current
6
- * branch, so it stays correct across /tree navigation and forks.
4
+ * Honors Claude Code's `statusLine` settings contract: a configured command runs
5
+ * with the session JSON on stdin (model, workspace, cost, context_window, effort,
6
+ * output_style, session ids) and its first stdout line becomes the footer segment,
7
+ * padded per `padding`. It re-runs, debounced 300ms as Claude does, at session
8
+ * start, after turns, after compaction, on plan-mode changes (the permission-mode
9
+ * analogue, off the shared bus), and on the optional `refreshInterval` timer
10
+ * (minimum 1s). A project-defined command is arbitrary shell, so project settings
11
+ * count only once the project is already approved, read without prompting.
7
12
  *
8
- * pi's built-in footer already shows path, branch, context, and model;
9
- * this extension only adds what is missing instead of replacing the footer.
13
+ * Without a configured statusLine, the built-in segment shows turn state plus
14
+ * running session cost, summed from per-message usage on the current branch so it
15
+ * stays correct across /tree navigation and forks. The built-in segment is also
16
+ * the fallback while a configured command produces no output. Multi-line output
17
+ * is truncated to its first line: the segment is one footer row in pi.
18
+ *
19
+ * Docs: https://code.claude.com/docs/en/statusline.md
10
20
  */
11
21
 
22
+ import * as fs from 'node:fs'
23
+ import * as os from 'node:os'
12
24
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
13
25
 
26
+ import { hookFiles, runHookCommand } from './hooks.js'
27
+ import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
28
+ import { isProjectApprovedSilently } from './internal/project-approval.js'
29
+ import { readActiveStyleName, settingsFiles } from './output-styles.js'
30
+
31
+ const COMMAND_TIMEOUT_MS = 5_000
32
+ const DEBOUNCE_MS = 300
33
+
14
34
  interface UsageEntry {
15
35
  type: string
16
36
  message?: { usage?: { cost?: { total?: number } } }
@@ -28,34 +48,156 @@ function formatCost(cost: number): string {
28
48
  return cost >= 0.01 ? `$${cost.toFixed(2)}` : `$${cost.toFixed(4)}`
29
49
  }
30
50
 
51
+ export interface StatusLineConfig {
52
+ command: string
53
+ padding: number
54
+ refreshInterval: number | undefined
55
+ }
56
+
57
+ /** The `statusLine` recorded in settings, last file winning. Claude's shape is
58
+ * `{type: "command", command, padding?, refreshInterval?}`; entries without a
59
+ * command string are ignored, and refreshInterval has a documented minimum of 1. */
60
+ export function readStatusLineConfig(files: string[]): StatusLineConfig | undefined {
61
+ let found: StatusLineConfig | undefined
62
+ for (const file of files) {
63
+ try {
64
+ const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
65
+ const entry = settings.statusLine
66
+ if (!entry || typeof entry.command !== 'string') continue
67
+ if (entry.type !== undefined && entry.type !== 'command') continue
68
+ found = {
69
+ command: entry.command,
70
+ padding: typeof entry.padding === 'number' && entry.padding > 0 ? entry.padding : 0,
71
+ refreshInterval: typeof entry.refreshInterval === 'number' && entry.refreshInterval >= 1 ? entry.refreshInterval : undefined,
72
+ }
73
+ } catch {
74
+ // missing or invalid file: skip
75
+ }
76
+ }
77
+ return found
78
+ }
79
+
31
80
  export default function statusLine(pi: ExtensionAPI) {
32
81
  let turnCount = 0
82
+ let config: StatusLineConfig | undefined
83
+ let sessionCtx: ExtensionContext | undefined
84
+ let commandLine: string | undefined
85
+ let permissionMode = 'default'
86
+ let refreshTimer: ReturnType<typeof setInterval> | undefined
87
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined
88
+ let running = false
89
+ let rerunQueued = false
33
90
 
34
- function showIdle(ctx: ExtensionContext, symbol: string): void {
91
+ function segmentText(ctx: ExtensionContext, symbol: string): string {
35
92
  const theme = ctx.ui.theme
36
93
  const cost = sessionCost(ctx)
37
94
  const costText = cost > 0 ? theme.fg('muted', ` ${formatCost(cost)}`) : ''
38
95
  const turnText = turnCount > 0 ? theme.fg('dim', ` turn ${turnCount}`) : theme.fg('dim', ' ready')
39
- ctx.ui.setStatus('pi-code-status', symbol + turnText + costText)
96
+ return symbol + turnText + costText
97
+ }
98
+
99
+ function show(ctx: ExtensionContext, builtIn: string): void {
100
+ ctx.ui.setStatus('pi-code-status', commandLine ?? builtIn)
101
+ }
102
+
103
+ /** The stdin payload per Claude's documented statusline contract. */
104
+ function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
105
+ const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
106
+ const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), true))
107
+ const payload: Record<string, unknown> = {
108
+ session_id: ctx.sessionManager.getSessionId(),
109
+ cwd: ctx.cwd,
110
+ workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
111
+ model: { id: (ctx.model as { id?: string } | undefined)?.id ?? '' },
112
+ cost: { total_cost_usd: sessionCost(ctx) },
113
+ context_window: { context_window_size: usage.contextWindow, used_percentage: usage.percent, total_input_tokens: usage.tokens },
114
+ permission_mode: permissionMode,
115
+ }
116
+ const transcript = ctx.sessionManager.getSessionFile()
117
+ if (transcript) payload.transcript_path = transcript
118
+ if (ctx.thinkingLevel) payload.effort = { level: ctx.thinkingLevel }
119
+ if (styleName) payload.output_style = { name: styleName }
120
+ return payload
121
+ }
122
+
123
+ async function runCommand(ctx: ExtensionContext): Promise<void> {
124
+ if (!config) return
125
+ if (running) {
126
+ rerunQueued = true
127
+ return
128
+ }
129
+ running = true
130
+ try {
131
+ const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
132
+ const first = result.stdout.split('\n')[0].trimEnd()
133
+ const pad = ' '.repeat(config.padding)
134
+ commandLine = first ? `${pad}${first}${pad}` : undefined
135
+ show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
136
+ } finally {
137
+ running = false
138
+ if (rerunQueued) {
139
+ rerunQueued = false
140
+ void runCommand(ctx)
141
+ }
142
+ }
40
143
  }
41
144
 
145
+ /** Claude debounces statusline updates at 300ms so rapid triggers batch. */
146
+ function scheduleRefresh(): void {
147
+ if (!config || !sessionCtx) return
148
+ const ctx = sessionCtx
149
+ clearTimeout(debounceTimer)
150
+ debounceTimer = setTimeout(() => {
151
+ void runCommand(ctx)
152
+ }, DEBOUNCE_MS)
153
+ }
154
+
155
+ pi.events.on(PLAN_MODE_CHANNEL, (data) => {
156
+ if (!isPlanModeState(data)) return
157
+ permissionMode = data.active ? 'plan' : 'default'
158
+ scheduleRefresh()
159
+ })
160
+
42
161
  pi.on('session_start', async (_event, ctx) => {
43
- // One instance serves every session, so a fresh session must not inherit the count.
162
+ // One instance serves every session, so a fresh session must not inherit state.
44
163
  turnCount = 0
45
- showIdle(ctx, ctx.ui.theme.fg('dim', '○'))
164
+ commandLine = undefined
165
+ sessionCtx = ctx
166
+ clearInterval(refreshTimer)
167
+ // Reading config must never open a trust dialog: several extensions resolve
168
+ // approval at session start, and a second prompt stacks over the first and eats
169
+ // the keys meant for it. An undecided project simply skips project settings.
170
+ const trusted = isProjectApprovedSilently(ctx)
171
+ config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
172
+ if (config?.refreshInterval) {
173
+ refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
174
+ }
175
+ show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
176
+ scheduleRefresh()
46
177
  })
47
178
 
48
179
  pi.on('turn_start', async (_event, ctx) => {
49
180
  turnCount++
50
181
  const theme = ctx.ui.theme
51
- ctx.ui.setStatus('pi-code-status', theme.fg('accent', '●') + theme.fg('dim', ` turn ${turnCount}...`))
182
+ show(ctx, theme.fg('accent', '●') + theme.fg('dim', ` turn ${turnCount}...`))
52
183
  })
53
184
 
54
185
  pi.on('turn_end', async (_event, ctx) => {
55
- showIdle(ctx, ctx.ui.theme.fg('success', '✓'))
186
+ show(ctx, segmentText(ctx, ctx.ui.theme.fg('success', '✓')))
187
+ scheduleRefresh()
56
188
  })
57
189
 
58
190
  pi.on('agent_end', async (_event, ctx) => {
59
- showIdle(ctx, ctx.ui.theme.fg('success', '✓'))
191
+ show(ctx, segmentText(ctx, ctx.ui.theme.fg('success', '✓')))
192
+ scheduleRefresh()
193
+ })
194
+
195
+ pi.on('session_compact', async (_event, _ctx) => {
196
+ scheduleRefresh()
197
+ })
198
+
199
+ pi.on('session_shutdown', async () => {
200
+ clearInterval(refreshTimer)
201
+ clearTimeout(debounceTimer)
60
202
  })
61
203
  }
@@ -21,15 +21,10 @@ subagent/
21
21
  ├── index.ts # The extension (entry point)
22
22
  ├── agents.ts # Agent discovery logic
23
23
  ├── background.ts # Background run registry and spawning
24
- ├── agents/ # Sample agent definitions
25
- │ ├── scout.md # Fast recon, returns compressed context
26
- │ ├── planner.md # Creates implementation plans
27
- ├── reviewer.md # Code review
28
- │ └── worker.md # General-purpose (full capabilities)
29
- └── prompts/ # Workflow presets (prompt templates)
30
- ├── implement.md # scout -> planner -> worker
31
- ├── scout-and-plan.md # scout -> planner (no implementation)
32
- └── implement-and-review.md # worker -> reviewer -> worker
24
+ ├── agents/ # Bundled builtin agents, always available (lowest precedence)
25
+ │ ├── explore.md # Explore: fast read-only codebase exploration
26
+ │ ├── plan.md # Plan: read-only implementation planning
27
+ └── general-purpose.md # general-purpose: full capabilities
33
28
  ```
34
29
 
35
30
  ## Installation
@@ -42,7 +37,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
42
37
 
43
38
  **Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
44
39
 
45
- **Default behavior:** Only loads **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`.
40
+ **Default behavior:** Loads the bundled builtin agents (Explore, Plan, general-purpose) plus **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`. A user or project agent with the same name overrides a builtin. Discovered agents and their descriptions are listed in the system prompt each turn, so the model can pick one itself; project agent descriptions appear only once the project is approved.
46
41
 
47
42
  To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
48
43
 
@@ -52,24 +47,17 @@ When running interactively, the tool prompts for confirmation before running pro
52
47
 
53
48
  ### Single agent
54
49
  ```
55
- Use scout to find all authentication code
50
+ Use Explore to find all authentication code
56
51
  ```
57
52
 
58
53
  ### Parallel execution
59
54
  ```
60
- Run 2 scouts in parallel: one to find models, one to find providers
55
+ Run 2 Explore agents in parallel: one to find models, one to find providers
61
56
  ```
62
57
 
63
58
  ### Chained workflow
64
59
  ```
65
- Use a chain: first have scout find the read tool, then have planner suggest improvements
66
- ```
67
-
68
- ### Workflow prompts
69
- ```
70
- /implement add Redis caching to the session store
71
- /scout-and-plan refactor auth to support OAuth
72
- /implement-and-review add input validation to API endpoints
60
+ Use a chain: first have Explore find the read tool, then have Plan suggest improvements
73
61
  ```
74
62
 
75
63
  ## Tool Modes
@@ -135,22 +123,15 @@ model. Fields with no pi equivalent are ignored: `skills`, `memory`,
135
123
 
136
124
  Project agents override user agents with the same name when `agentScope: "both"`.
137
125
 
138
- ## Sample Agents
139
-
140
- | Agent | Purpose | Model | Tools |
141
- |-------|---------|-------|-------|
142
- | `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash |
143
- | `planner` | Implementation plans | Sonnet | read, grep, find, ls |
144
- | `reviewer` | Code review | Sonnet | read, grep, find, ls, bash |
145
- | `worker` | General-purpose | Sonnet | (all default) |
126
+ ## Builtin Agents
146
127
 
147
- ## Workflow Prompts
128
+ | Agent | Purpose | Tools |
129
+ |-------|---------|-------|
130
+ | `Explore` | Fast read-only codebase exploration | read, grep, find, ls |
131
+ | `Plan` | Read-only implementation planning | read, grep, find, ls |
132
+ | `general-purpose` | Full capabilities, isolated context | (all default) |
148
133
 
149
- | Prompt | Flow |
150
- |--------|------|
151
- | `/implement <query>` | scout → planner → worker |
152
- | `/scout-and-plan <query>` | scout → planner |
153
- | `/implement-and-review <query>` | worker → reviewer → worker |
134
+ No model is pinned: each runs on the session's default model.
154
135
 
155
136
  ## Error Handling
156
137
 
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: Explore
3
+ description: Fast read-only codebase exploration that returns compressed findings
4
+ tools: read, grep, find, ls
5
+ ---
6
+
7
+ You are an exploration agent. Quickly investigate the codebase and return structured findings that another agent can use without re-reading everything.
8
+
9
+ You must NOT make any changes: only read, search, and summarize.
10
+
11
+ Your output goes to an agent who has NOT seen the files you explored. Report:
12
+
13
+ 1. Relevant files with paths and one-line roles
14
+ 2. Key functions/types with `file:line` references
15
+ 3. How the pieces connect (data flow, call flow)
16
+ 4. Anything surprising or risky
17
+
18
+ Be selective: compressed, load-bearing findings beat exhaustive dumps.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: general-purpose
3
+ description: General-purpose agent with full capabilities in an isolated context
4
+ ---
5
+
6
+ You are a general-purpose agent with full capabilities, operating in an isolated context window to handle delegated tasks without polluting the main conversation.
7
+
8
+ Work autonomously to complete the assigned task, using the available tools as needed.
9
+
10
+ When finished, report: what was done, what was verified (commands run, tests passed), and anything the delegator must know (caveats, follow-ups, files changed).
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: Plan
3
+ description: Designs implementation plans from context and requirements, read-only
4
+ tools: read, grep, find, ls
5
+ ---
6
+
7
+ You are a planning specialist. You receive context and requirements, then produce a clear implementation plan.
8
+
9
+ You must NOT make any changes: only read, analyze, and plan.
10
+
11
+ Deliver:
12
+
13
+ 1. Step-by-step plan, each step small and independently verifiable
14
+ 2. Files to touch per step, with `file:line` anchors where known
15
+ 3. Risks and open questions, each with a suggested resolution
16
+ 4. What to test and how the tests would fail without the change
@@ -68,7 +68,7 @@ function parseEffortField(raw: unknown): string | undefined {
68
68
  const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
69
69
 
70
70
  /** Parse one agent markdown file; null when it is not a usable agent definition. */
71
- function parseAgentFile(content: string, source: 'user' | 'project', filePath: string): AgentConfig | null {
71
+ function parseAgentFile(content: string, source: AgentSource, filePath: string): AgentConfig | null {
72
72
  let parsed: { frontmatter: Record<string, unknown>; body: string }
73
73
  try {
74
74
  parsed = parseFrontmatter<Record<string, unknown>>(content)
@@ -106,7 +106,7 @@ export interface AgentConfig {
106
106
  model?: string
107
107
  effort?: string
108
108
  systemPrompt: string
109
- source: 'user' | 'project'
109
+ source: AgentSource
110
110
  filePath: string
111
111
  }
112
112
 
@@ -115,7 +115,7 @@ export interface AgentDiscoveryResult {
115
115
  projectAgentsDir: string | null
116
116
  }
117
117
 
118
- function loadAgentsFromDir(dir: string, source: 'user' | 'project'): AgentConfig[] {
118
+ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
119
119
  const agents: AgentConfig[] = []
120
120
 
121
121
  if (!fs.existsSync(dir)) {
@@ -202,6 +202,11 @@ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[],
202
202
  return agentMap
203
203
  }
204
204
 
205
+ export type AgentSource = 'user' | 'project' | 'builtin'
206
+
207
+ /** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
208
+ export const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
209
+
205
210
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
206
211
  const userDir = path.join(getAgentDir(), 'agents')
207
212
  const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
@@ -209,7 +214,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
209
214
  const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
210
215
 
211
216
  // ~/.claude/agents loads first so ~/.pi/agent/agents wins on name conflicts
212
- const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
217
+ const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
213
218
  // project .claude/agents loads first so project .pi/agents wins on name conflicts
214
219
  const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
215
220
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
+ import { randomUUID } from 'node:crypto'
16
17
  import * as fs from 'node:fs'
17
18
  import * as os from 'node:os'
18
19
  import * as path from 'node:path'
@@ -23,7 +24,8 @@ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme,
23
24
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
24
25
  import { type Static, Type } from 'typebox'
25
26
  import { capForContext } from '../internal/output-guard.js'
26
- import { isProjectApproved } from '../internal/project-approval.js'
27
+ import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
28
+ import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
27
29
  import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
28
30
  import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
29
31
 
@@ -137,7 +139,7 @@ interface UsageStats {
137
139
 
138
140
  interface SingleResult {
139
141
  agent: string
140
- agentSource: 'user' | 'project' | 'unknown'
142
+ agentSource: 'user' | 'project' | 'builtin' | 'unknown'
141
143
  task: string
142
144
  exitCode: number
143
145
  messages: Message[]
@@ -256,9 +258,25 @@ interface RunAgentOptions {
256
258
  signal?: AbortSignal
257
259
  onUpdate?: OnUpdateCallback
258
260
  makeDetails: (results: SingleResult[]) => SubagentDetails
261
+ onPhase?: SubagentPhaseSink
259
262
  }
260
263
 
264
+ /** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
265
+ type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string) => void
266
+
261
267
  async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
268
+ const agent = options.agents.find((a) => a.name === options.agentName)
269
+ if (!agent) return runSingleAgentInner(options)
270
+ const agentId = `fg-${randomUUID().slice(0, 8)}`
271
+ options.onPhase?.('start', agent.name, agentId)
272
+ try {
273
+ return await runSingleAgentInner(options)
274
+ } finally {
275
+ options.onPhase?.('stop', agent.name, agentId)
276
+ }
277
+ }
278
+
279
+ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResult> {
262
280
  const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
263
281
  const agent = agents.find((a) => a.name === agentName)
264
282
 
@@ -478,6 +496,7 @@ interface ModeContext {
478
496
  signal: AbortSignal | undefined
479
497
  onUpdate: OnUpdateCallback | undefined
480
498
  makeDetails: MakeDetails
499
+ onPhase?: SubagentPhaseSink
481
500
  }
482
501
 
483
502
  async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
@@ -572,6 +591,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
572
591
  const invocation = getPiInvocation(args)
573
592
  const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
574
593
  removeTmpPrompt(tmpPrompt)
594
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
575
595
  const output = capForContext(run.output ?? '') || '(no output)'
576
596
  pi.sendMessage(
577
597
  {
@@ -587,6 +607,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
587
607
  removeTmpPrompt(tmpPrompt)
588
608
  return backgroundCapResult(makeDetails)
589
609
  }
610
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
590
611
  return {
591
612
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
592
613
  details: makeDetails('single')([]),
@@ -628,6 +649,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
628
649
  signal,
629
650
  onUpdate: chainUpdate,
630
651
  makeDetails: makeDetails('chain'),
652
+ onPhase: mode.onPhase,
631
653
  })
632
654
  results.push(result)
633
655
 
@@ -695,6 +717,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
695
717
  task: t.task,
696
718
  cwd: t.cwd,
697
719
  signal,
720
+ onPhase: mode.onPhase,
698
721
  // Per-task update callback
699
722
  onUpdate: (partial) => {
700
723
  const live = partial.details?.results[0]
@@ -741,6 +764,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
741
764
  signal,
742
765
  onUpdate,
743
766
  makeDetails: makeDetails('single'),
767
+ onPhase: mode.onPhase,
744
768
  })
745
769
  const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
746
770
  if (isError) {
@@ -1044,6 +1068,19 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1044
1068
  }
1045
1069
 
1046
1070
  export default function subagentExtension(pi: ExtensionAPI) {
1071
+ // Claude surfaces each agent's description so the model can pick one autonomously.
1072
+ // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1073
+ // included only when the project is already approved, read without prompting, since
1074
+ // a trust dialog must not appear mid-turn and their descriptions are project text.
1075
+ pi.on('before_agent_start', async (event, ctx) => {
1076
+ const scope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1077
+ const { agents } = discoverAgents(ctx.cwd, scope)
1078
+ if (agents.length === 0) return
1079
+ const line = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 200)
1080
+ const roster = agents.map((agent) => `- ${agent.name} (${agent.source}): ${line(agent.description)}`).join('\n')
1081
+ return { systemPrompt: `${event.systemPrompt}\n\n## Subagents\n\nDelegate isolated tasks with the subagent tool ({agent, task}). Available agents:\n${roster}` }
1082
+ })
1083
+
1047
1084
  pi.registerTool({
1048
1085
  name: 'subagent',
1049
1086
  label: 'Subagent',
@@ -1109,7 +1146,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1109
1146
 
1110
1147
  if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails)
1111
1148
 
1112
- const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails }
1149
+ const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1113
1150
 
1114
1151
  if (params.chain?.length) return runChainMode(params.chain, mode)
1115
1152
  if (params.tasks?.length) return runParallelMode(params.tasks, mode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",
@@ -1,37 +0,0 @@
1
- ---
2
- name: planner
3
- description: Creates implementation plans from context and requirements
4
- tools: read, grep, find, ls
5
- model: claude-sonnet-4-5
6
- ---
7
-
8
- You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
9
-
10
- You must NOT make any changes. Only read, analyze, and plan.
11
-
12
- Input format you'll receive:
13
- - Context/findings from a scout agent
14
- - Original query or requirements
15
-
16
- Output format:
17
-
18
- ## Goal
19
- One sentence summary of what needs to be done.
20
-
21
- ## Plan
22
- Numbered steps, each small and actionable:
23
- 1. Step one - specific file/function to modify
24
- 2. Step two - what to add/change
25
- 3. ...
26
-
27
- ## Files to Modify
28
- - `path/to/file.ts` - what changes
29
- - `path/to/other.ts` - what changes
30
-
31
- ## New Files (if any)
32
- - `path/to/new.ts` - purpose
33
-
34
- ## Risks
35
- Anything to watch out for.
36
-
37
- Keep the plan concrete. The worker agent will execute it verbatim.
@@ -1,35 +0,0 @@
1
- ---
2
- name: reviewer
3
- description: Code review specialist for quality and security analysis
4
- tools: read, grep, find, ls, bash
5
- model: claude-sonnet-4-5
6
- ---
7
-
8
- You are a senior code reviewer. Analyze code for quality, security, and maintainability.
9
-
10
- Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
11
- Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
12
-
13
- Strategy:
14
- 1. Run `git diff` to see recent changes (if applicable)
15
- 2. Read the modified files
16
- 3. Check for bugs, security issues, code smells
17
-
18
- Output format:
19
-
20
- ## Files Reviewed
21
- - `path/to/file.ts` (lines X-Y)
22
-
23
- ## Critical (must fix)
24
- - `file.ts:42` - Issue description
25
-
26
- ## Warnings (should fix)
27
- - `file.ts:100` - Issue description
28
-
29
- ## Suggestions (consider)
30
- - `file.ts:150` - Improvement idea
31
-
32
- ## Summary
33
- Overall assessment in 2-3 sentences.
34
-
35
- Be specific with file paths and line numbers.