pi-code 0.6.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.
package/README.md CHANGED
@@ -39,18 +39,18 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
39
39
  | Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
40
40
  | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
41
  | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
42
- | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
42
+ | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
43
43
  | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
44
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
44
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
45
45
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
- | Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
46
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; background runs | `subagent/` |
47
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
48
48
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
49
49
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
50
  | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
51
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
52
  | AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
53
- | Statusline | turn state + session cost | `status-line.ts` |
53
+ | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
54
54
  | Notifications | vendored example | `notify.ts` |
55
55
 
56
56
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Explanatory
3
+ description: Educational insights while completing tasks
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Between completing software engineering tasks, provide brief educational "Insights" that help the user understand implementation choices and codebase patterns.
8
+
9
+ Mark each one clearly:
10
+
11
+ `✻ Insight ─────`
12
+ (2-3 sentences on why this approach, what pattern it follows, or what tradeoff it makes)
13
+ `─────`
14
+
15
+ Share an insight when there is a genuine decision or pattern worth understanding, not after every step. Keep the work itself unchanged: insights are commentary, never a substitute for doing the task.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Learning
3
+ description: Collaborative learn-by-doing with small human-written pieces
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Work collaboratively, learn-by-doing style: share brief "Insights" explaining implementation choices as you work, and regularly ask the user to contribute small, strategic pieces of code themselves.
8
+
9
+ When a piece is well-scoped for the user to write (a condition, a small function body, a test assertion), leave a marker instead of writing it:
10
+
11
+ ```
12
+ // TODO(human): <one sentence describing exactly what to implement here>
13
+ ```
14
+
15
+ Then stop and ask the user to fill it in, explaining what the piece needs to do and why it matters. Choose pieces that teach something about the codebase or the problem, not busywork. Keep your own contributions moving the task forward between their turns.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: Proactive
3
+ description: Execute immediately, prefer action over planning
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Execute immediately. Make reasonable assumptions instead of pausing for routine decisions, and prefer action over planning: when a step is reversible and follows from the task, do it rather than proposing it.
8
+
9
+ Ask only when a decision is genuinely the user's to make (destructive actions, real scope changes). Report what you did and why afterward, concisely.
@@ -69,6 +69,16 @@ const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MC
69
69
  * `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
70
70
  * that cannot ask has not been approved.
71
71
  */
72
+ /** The same decision as isProjectApproved, but never prompts: an undecided project
73
+ * reads as unapproved. For surfaces that only display project config, like the
74
+ * subagent roster, where a mid-turn dialog would be wrong. */
75
+ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): boolean {
76
+ if (ctx.isProjectTrusted?.() !== true) return false
77
+ if (!deps.hasClaudeShaped(ctx.cwd)) return true
78
+ if (deps.piWouldAsk(ctx.cwd)) return true
79
+ return deps.savedDecision(ctx.cwd) === true
80
+ }
81
+
72
82
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
73
83
  if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
74
84
  if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
package/extensions/mcp.ts CHANGED
@@ -92,6 +92,37 @@ export function projectConfigPaths(cwd: string): string[] {
92
92
  return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
93
93
  }
94
94
 
95
+ export interface ProjectServerPolicy {
96
+ disabled: Set<string>
97
+ consented: Set<string>
98
+ consentAll: boolean
99
+ }
100
+
101
+ /** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
102
+ * (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
103
+ * does not control (user settings and settings.local.json), so a checked-in
104
+ * settings.json cannot approve its own servers. disabledMcpjsonServers counts from
105
+ * every file and wins over consent. Lists union across files: for denies the union is
106
+ * the restrictive reading, and consent is the union of the user's own two files. */
107
+ export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
108
+ const read = (file: string): Record<string, unknown> => {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
111
+ } catch {
112
+ return {}
113
+ }
114
+ }
115
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
116
+ const userSettings = read(path.join(home, '.claude', 'settings.json'))
117
+ const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
118
+ const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
119
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
120
+ const consentSources = [userSettings, localSettings]
121
+ const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
122
+ const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
123
+ return { disabled, consented, consentAll }
124
+ }
125
+
95
126
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
96
127
  const servers: Record<string, ServerConfig> = {}
97
128
  for (const file of files) {
@@ -325,11 +356,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
325
356
  userConnected = true
326
357
  await connectServers(loadUserScope(os.homedir(), ctx.cwd))
327
358
  }
328
- // A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
329
- // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
330
- if (!projectConnected && (await isProjectApproved(ctx))) {
331
- projectConnected = true
332
- await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
359
+ // A project .mcp.json can run arbitrary commands on connect, so only honor it once
360
+ // the project is trusted. Per-server settings refine that: disabled servers never
361
+ // connect, servers the user consented to individually connect without the
362
+ // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
363
+ // refusal are safe: connectServers skips names that already connected.
364
+ if (!projectConnected) {
365
+ const policy = projectServerPolicy(ctx.cwd, os.homedir())
366
+ const candidates = loadConfigFrom(projectConfigPaths(ctx.cwd))
367
+ const consented: Record<string, ServerConfig> = {}
368
+ const gated: Record<string, ServerConfig> = {}
369
+ for (const [name, config] of Object.entries(candidates)) {
370
+ if (policy.disabled.has(name)) continue
371
+ if (policy.consentAll || policy.consented.has(name)) consented[name] = config
372
+ else gated[name] = config
373
+ }
374
+ if (Object.keys(consented).length > 0) await connectServers(consented)
375
+ if (Object.keys(gated).length === 0) projectConnected = true
376
+ else if (await isProjectApproved(ctx)) {
377
+ projectConnected = true
378
+ await connectServers(gated)
379
+ }
333
380
  }
334
381
 
335
382
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
@@ -8,8 +8,11 @@
8
8
  * tone and role. `/output-style` lists the styles and persists a choice to the
9
9
  * project's settings.local.json.
10
10
  *
11
- * pi keeps its own base system prompt (tools, safety); the style is layered on
12
- * top rather than replacing it wholesale.
11
+ * Claude semantics: a style replaces the built-in coding instructions unless its
12
+ * frontmatter sets `keep-coding-instructions: true`. The replacement excises pi's
13
+ * default coding prose up to a stable marker line and keeps everything after it
14
+ * (append text, project context, skills, other extensions' additions); when the
15
+ * marker is absent (custom SYSTEM.md), the style falls back to appending.
13
16
  *
14
17
  * Docs: https://code.claude.com/docs/en/output-styles.md
15
18
  */
@@ -25,6 +28,7 @@ export interface OutputStyle {
25
28
  name: string
26
29
  description: string
27
30
  body: string
31
+ keepCodingInstructions: boolean
28
32
  }
29
33
 
30
34
  function field(frontmatter: string, key: string): string {
@@ -37,7 +41,27 @@ export function parseStyle(content: string, fallbackName: string): OutputStyle {
37
41
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
38
42
  const frontmatter = match ? match[1] : ''
39
43
  const body = match ? content.slice(match[0].length) : content
40
- return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim() }
44
+ return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim(), keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true' }
45
+ }
46
+
47
+ /** Equivalents of Claude's built-in styles, shipped with pi-code as the
48
+ * lowest-precedence source: a user or project style of the same name wins. */
49
+ export const BUILTIN_STYLES_DIR = path.join(import.meta.dirname, 'internal', 'builtin-styles')
50
+
51
+ /** The last line of pi's default coding instructions. Everything after it (append
52
+ * text, project context, skills, cwd, other extensions' additions) survives a style
53
+ * replacement. Tracks pi's dist/core/system-prompt.js; a canary test pins it. */
54
+ export const CODING_BASE_MARKER = '- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)'
55
+
56
+ /** Apply a style per Claude semantics: replace the coding instructions unless the
57
+ * style keeps them; fall back to appending when the marker is absent. */
58
+ export function applyStyle(systemPrompt: string, style: OutputStyle): string {
59
+ const styleSection = `## Output Style: ${style.name}\n\n${style.body}`
60
+ if (!style.keepCodingInstructions) {
61
+ const idx = systemPrompt.indexOf(CODING_BASE_MARKER)
62
+ if (idx !== -1) return `${styleSection}${systemPrompt.slice(idx + CODING_BASE_MARKER.length)}`
63
+ }
64
+ return `${systemPrompt}\n\n${styleSection}`
41
65
  }
42
66
 
43
67
  function isDirectory(target: string): boolean {
@@ -132,7 +156,7 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
132
156
  // project styles / selection once the project is approved. isProjectTrusted alone
133
157
  // is true for a repo pi never asked about; see project-approval.
134
158
  const trusted = await isProjectApproved(ctx)
135
- styles = loadStyles(styleDirs(ctx.cwd, home, trusted))
159
+ styles = loadStyles([BUILTIN_STYLES_DIR, ...styleDirs(ctx.cwd, home, trusted)])
136
160
  localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
137
161
  activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
138
162
  const active = styleForName(styles, activeName)
@@ -142,12 +166,24 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
142
166
  pi.on('before_agent_start', async (event) => {
143
167
  const active = styleForName(styles, activeName)
144
168
  if (!active || active.body.length === 0) return
145
- return { systemPrompt: `${event.systemPrompt}\n\n## Output Style: ${active.name}\n\n${active.body}` }
169
+ return { systemPrompt: applyStyle(event.systemPrompt, active) }
146
170
  })
147
171
 
148
172
  pi.registerCommand('output-style', {
149
- description: 'Choose the active Claude output style',
150
- handler: async (_args, ctx) => {
173
+ description: 'Choose the active Claude output style (or /output-style <name>)',
174
+ handler: async (args, ctx) => {
175
+ const requested = args.trim()
176
+ if (requested) {
177
+ const picked = styles.find((style) => style.name.toLowerCase() === requested.toLowerCase())
178
+ if (!picked) {
179
+ ctx.ui.notify(`Unknown output style: ${requested}. Available: ${styles.map((style) => style.name).join(', ')}`, 'error')
180
+ return
181
+ }
182
+ activeName = picked.name
183
+ persistActiveStyle(localSettingsPath, picked.name)
184
+ ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
185
+ return
186
+ }
151
187
  if (!ctx.hasUI) {
152
188
  ctx.ui.notify('/output-style requires interactive mode', 'error')
153
189
  return
@@ -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
 
@@ -24,7 +24,7 @@ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme,
24
24
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
25
25
  import { type Static, Type } from 'typebox'
26
26
  import { capForContext } from '../internal/output-guard.js'
27
- import { isProjectApproved } from '../internal/project-approval.js'
27
+ import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
28
28
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
29
29
  import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
30
30
  import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
@@ -139,7 +139,7 @@ interface UsageStats {
139
139
 
140
140
  interface SingleResult {
141
141
  agent: string
142
- agentSource: 'user' | 'project' | 'unknown'
142
+ agentSource: 'user' | 'project' | 'builtin' | 'unknown'
143
143
  task: string
144
144
  exitCode: number
145
145
  messages: Message[]
@@ -1068,6 +1068,19 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1068
1068
  }
1069
1069
 
1070
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
+
1071
1084
  pi.registerTool({
1072
1085
  name: 'subagent',
1073
1086
  label: 'Subagent',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.6.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.
@@ -1,50 +0,0 @@
1
- ---
2
- name: scout
3
- description: Fast codebase recon that returns compressed context for handoff to other agents
4
- tools: read, grep, find, ls, bash
5
- model: claude-haiku-4-5
6
- ---
7
-
8
- You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
9
-
10
- Your output will be passed to an agent who has NOT seen the files you explored.
11
-
12
- Thoroughness (infer from task, default medium):
13
- - Quick: Targeted lookups, key files only
14
- - Medium: Follow imports, read critical sections
15
- - Thorough: Trace all dependencies, check tests/types
16
-
17
- Strategy:
18
- 1. grep/find to locate relevant code
19
- 2. Read key sections (not entire files)
20
- 3. Identify types, interfaces, key functions
21
- 4. Note dependencies between files
22
-
23
- Output format:
24
-
25
- ## Files Retrieved
26
- List with exact line ranges:
27
- 1. `path/to/file.ts` (lines 10-50) - Description of what's here
28
- 2. `path/to/other.ts` (lines 100-150) - Description
29
- 3. ...
30
-
31
- ## Key Code
32
- Critical types, interfaces, or functions:
33
-
34
- ```typescript
35
- interface Example {
36
- // actual code from the files
37
- }
38
- ```
39
-
40
- ```typescript
41
- function keyFunction() {
42
- // actual implementation
43
- }
44
- ```
45
-
46
- ## Architecture
47
- Brief explanation of how the pieces connect.
48
-
49
- ## Start Here
50
- Which file to look at first and why.
@@ -1,24 +0,0 @@
1
- ---
2
- name: worker
3
- description: General-purpose subagent with full capabilities, isolated context
4
- model: claude-sonnet-4-5
5
- ---
6
-
7
- You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
8
-
9
- Work autonomously to complete the assigned task. Use all available tools as needed.
10
-
11
- Output format when finished:
12
-
13
- ## Completed
14
- What was done.
15
-
16
- ## Files Changed
17
- - `path/to/file.ts` - what changed
18
-
19
- ## Notes (if any)
20
- Anything the main agent should know.
21
-
22
- If handing off to another agent (e.g. reviewer), include:
23
- - Exact file paths changed
24
- - Key functions/types touched (short list)
@@ -1,10 +0,0 @@
1
- ---
2
- description: Worker implements, reviewer reviews, worker applies feedback
3
- ---
4
- Use the subagent tool with the chain parameter to execute this workflow:
5
-
6
- 1. First, use the "worker" agent to implement: $@
7
- 2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
8
- 3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
9
-
10
- Execute this as a chain, passing output between steps via {previous}.
@@ -1,10 +0,0 @@
1
- ---
2
- description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
3
- ---
4
- Use the subagent tool with the chain parameter to execute this workflow:
5
-
6
- 1. First, use the "scout" agent to find all code relevant to: $@
7
- 2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
8
- 3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
9
-
10
- Execute this as a chain, passing output between steps via {previous}.
@@ -1,9 +0,0 @@
1
- ---
2
- description: Scout gathers context, planner creates implementation plan (no implementation)
3
- ---
4
- Use the subagent tool with the chain parameter to execute this workflow:
5
-
6
- 1. First, use the "scout" agent to find all code relevant to: $@
7
- 2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
8
-
9
- Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.