pi-code 0.6.0 → 0.8.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.
@@ -13,6 +13,7 @@
13
13
  * the checkpoint (files created after the checkpoint are left in place).
14
14
  */
15
15
 
16
+ import * as fs from 'node:fs'
16
17
  import * as os from 'node:os'
17
18
  import * as path from 'node:path'
18
19
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
@@ -28,6 +29,35 @@ interface Checkpoint {
28
29
  createdAt: string
29
30
  }
30
31
 
32
+ /** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold
33
+ * full snapshots of every non-ignored file, so unbounded retention grows under $HOME
34
+ * for the life of the machine. */
35
+ export const CHECKPOINT_RETENTION_DAYS = 30
36
+
37
+ /** Remove shadow repos untouched for longer than the retention window. The live
38
+ * session's repo is always kept, whatever its age: a long session's directory mtime
39
+ * can predate the window. Failures are ignored; this is housekeeping, not a gate. */
40
+ export function pruneCheckpointRepos(root: string, retentionDays: number, keepDir?: string): void {
41
+ let entries: fs.Dirent[]
42
+ try {
43
+ entries = fs.readdirSync(root, { withFileTypes: true })
44
+ } catch {
45
+ return
46
+ }
47
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue
50
+ const dir = path.join(root, entry.name)
51
+ if (keepDir && path.resolve(dir) === path.resolve(keepDir)) continue
52
+ try {
53
+ if (fs.statSync(dir).mtimeMs >= cutoff) continue
54
+ fs.rmSync(dir, { recursive: true, force: true })
55
+ } catch {
56
+ // a repo we cannot stat or remove stays; housekeeping must not break startup
57
+ }
58
+ }
59
+ }
60
+
31
61
  export function sessionSlug(sessionFile: string | undefined): string {
32
62
  if (!sessionFile) return `ephemeral-${process.pid}`
33
63
  return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
@@ -93,7 +123,9 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
93
123
  async function ensureShadow(ctx: ExtensionContext): Promise<void> {
94
124
  workTree = ctx.cwd
95
125
  const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
96
- shadowDir = path.join(os.homedir(), '.pi', 'agent', 'checkpoints', sessionSlug(sessionFile))
126
+ const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
127
+ shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
128
+ pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
97
129
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
98
130
  if (check.code !== 0) {
99
131
  await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
@@ -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
@@ -21,7 +21,7 @@
21
21
  import * as fs from 'node:fs'
22
22
  import * as os from 'node:os'
23
23
  import * as path from 'node:path'
24
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
24
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
25
25
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
26
26
  // SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
27
27
  // the old spec exist, so this stays as a fallback for the migration period.
@@ -60,6 +60,8 @@ export interface StdioServerConfig {
60
60
  args?: string[]
61
61
  env?: Record<string, string>
62
62
  cwd?: string
63
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
64
+ timeout?: number
63
65
  }
64
66
 
65
67
  export interface HttpServerConfig {
@@ -68,6 +70,8 @@ export interface HttpServerConfig {
68
70
  headers?: Record<string, string>
69
71
  bearerToken?: string
70
72
  bearerTokenEnv?: string
73
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
74
+ timeout?: number
71
75
  }
72
76
 
73
77
  export type ServerConfig = StdioServerConfig | HttpServerConfig
@@ -92,6 +96,50 @@ export function projectConfigPaths(cwd: string): string[] {
92
96
  return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
93
97
  }
94
98
 
99
+ export interface ProjectServerPolicy {
100
+ disabled: Set<string>
101
+ consented: Set<string>
102
+ consentAll: boolean
103
+ }
104
+
105
+ /** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
106
+ * (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
107
+ * does not control (user settings and settings.local.json), so a checked-in
108
+ * settings.json cannot approve its own servers. disabledMcpjsonServers counts from
109
+ * every file and wins over consent. Lists union across files: for denies the union is
110
+ * the restrictive reading, and consent is the union of the user's own two files. */
111
+ export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
112
+ const read = (file: string): Record<string, unknown> => {
113
+ try {
114
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
115
+ } catch {
116
+ return {}
117
+ }
118
+ }
119
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
120
+ const userSettings = read(path.join(home, '.claude', 'settings.json'))
121
+ const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
122
+ const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
123
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
124
+ const consentSources = [userSettings, localSettings]
125
+ const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
126
+ const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
127
+ return { disabled, consented, consentAll }
128
+ }
129
+
130
+ /** Split project servers by the per-server policy: never-connect, connect without the
131
+ * whole-project confirm, and still gated behind it. */
132
+ export function splitByPolicy(candidates: Record<string, ServerConfig>, policy: ProjectServerPolicy): { consented: Record<string, ServerConfig>; gated: Record<string, ServerConfig> } {
133
+ const consented: Record<string, ServerConfig> = {}
134
+ const gated: Record<string, ServerConfig> = {}
135
+ for (const [name, config] of Object.entries(candidates)) {
136
+ if (policy.disabled.has(name)) continue
137
+ if (policy.consentAll || policy.consented.has(name)) consented[name] = config
138
+ else gated[name] = config
139
+ }
140
+ return { consented, gated }
141
+ }
142
+
95
143
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
96
144
  const servers: Record<string, ServerConfig> = {}
97
145
  for (const file of files) {
@@ -122,6 +170,28 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
122
170
  return servers
123
171
  }
124
172
 
173
+ /** Claude reports a config entry that has a url but no type as an error; pi-code
174
+ * still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
175
+ /** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
176
+ * environment variable read as-is. */
177
+ export function resolveBearerToken(config: { bearerToken?: string; bearerTokenEnv?: string }): string | undefined {
178
+ if (config.bearerToken) return interpolateEnv(config.bearerToken)
179
+ if (config.bearerTokenEnv) return process.env[config.bearerTokenEnv]
180
+ return undefined
181
+ }
182
+
183
+ /** A server cwd expands ${VAR} then a leading ~, or stays unset. */
184
+ export function expandCwd(cwd: string | undefined): string | undefined {
185
+ if (!cwd) return undefined
186
+ return interpolateEnv(cwd).replace(/^~(?=\/|$)/, os.homedir())
187
+ }
188
+
189
+ export function warnOnTypelessUrl(name: string, config: ServerConfig): void {
190
+ if ('url' in config && config.type === undefined) {
191
+ console.warn(`pi-code-mcp: server ${name} declares a url with no "type"; add "type": "http" or "sse"`)
192
+ }
193
+ }
194
+
125
195
  export function formatToolName(server: string, tool: string): string {
126
196
  return `${server}_${tool}`.replaceAll('-', '_')
127
197
  }
@@ -197,7 +267,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
197
267
  command: interpolateEnv(config.command),
198
268
  args: (config.args ?? []).map((arg) => interpolateEnv(arg)),
199
269
  env,
200
- cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
270
+ cwd: expandCwd(config.cwd),
201
271
  stderr: 'ignore',
202
272
  })
203
273
  await connectWithTimeout(client, transport, `connect ${name}`)
@@ -205,7 +275,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
205
275
  }
206
276
  const headers: Record<string, string> = {}
207
277
  for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
208
- const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
278
+ const token = resolveBearerToken(config)
209
279
  if (token) headers.Authorization = `Bearer ${token}`
210
280
  const url = new URL(interpolateEnv(config.url))
211
281
  if (config.type === 'sse') {
@@ -274,6 +344,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
274
344
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
275
345
  continue
276
346
  }
347
+ warnOnTypelessUrl(name, config)
277
348
  try {
278
349
  const client = await connect(name, config)
279
350
  clients.set(name, client)
@@ -296,7 +367,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
296
367
  async execute(_id, params) {
297
368
  // Pass the timeout to the SDK too: its own default request timeout is 60s and
298
369
  // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
299
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
370
+ // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
371
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
372
+ const budget = declared ?? callTimeoutMs()
373
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
300
374
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
301
375
  const details: { error?: string } = {}
302
376
  if (result.isError) {
@@ -315,6 +389,18 @@ export default async function mcpExtension(pi: ExtensionAPI) {
315
389
  }
316
390
  }
317
391
 
392
+ /** Connect the project scope under the per-server policy. Returns whether the scope
393
+ * is settled, so a refused confirm can be retried on a later session start. */
394
+ async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
395
+ const policy = projectServerPolicy(ctx.cwd, os.homedir())
396
+ const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
397
+ if (Object.keys(consented).length > 0) await connectServers(consented)
398
+ if (Object.keys(gated).length === 0) return true
399
+ if (!(await isProjectApproved(ctx))) return false
400
+ await connectServers(gated)
401
+ return true
402
+ }
403
+
318
404
  let userConnected = false
319
405
  let projectConnected = false
320
406
 
@@ -325,12 +411,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
325
411
  userConnected = true
326
412
  await connectServers(loadUserScope(os.homedir(), ctx.cwd))
327
413
  }
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)))
333
- }
414
+ // A project .mcp.json can run arbitrary commands on connect, so only honor it once
415
+ // the project is trusted. Per-server settings refine that: disabled servers never
416
+ // connect, servers the user consented to individually connect without the
417
+ // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
418
+ // refusal are safe: connectServers skips names that already connected.
419
+ if (!projectConnected) projectConnected = await connectProjectScope(ctx)
334
420
 
335
421
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
336
422
 
@@ -7,6 +7,7 @@
7
7
  * memories through the memory tool (save / read / delete / list).
8
8
  */
9
9
 
10
+ import { createHash } from 'node:crypto'
10
11
  import * as fs from 'node:fs'
11
12
  import * as os from 'node:os'
12
13
  import * as path from 'node:path'
@@ -17,7 +18,27 @@ import { capForContext } from './internal/output-guard.js'
17
18
 
18
19
  const INDEX_FILE = 'MEMORY.md'
19
20
 
21
+ /** Claude loads the first 200 lines or 25KB of the memory index at startup. */
22
+ export const INDEX_MAX_LINES = 200
23
+ export const INDEX_MAX_BYTES = 25_000
24
+
25
+ /** Windows drive letters are case-insensitive, so C:\x and c:\x are one project. */
26
+ function normalizeCwd(cwd: string): string {
27
+ return cwd.replace(/^([A-Za-z]):(?=[/\\])/, (_match, letter: string) => letter.toUpperCase())
28
+ }
29
+
30
+ /** Readable dashed path plus a short digest of the real path. The digest is what makes
31
+ * the slug injective: every separator becomes a dash, so /a/b, /a-b and \a\b share a
32
+ * dashed form and would otherwise share one store. */
20
33
  export function projectSlug(cwd: string): string {
34
+ const normalized = normalizeCwd(cwd)
35
+ const readable = normalized.replace(/[/\\]/g, '-').replace(/^-+/, '-')
36
+ const digest = createHash('sha256').update(normalized).digest('hex').slice(0, 8)
37
+ return `${readable}-${digest}`
38
+ }
39
+
40
+ /** The pre-digest slug, kept only to migrate an existing store to the new name. */
41
+ function legacySlug(cwd: string): string {
21
42
  return cwd
22
43
  .replace(/^([A-Za-z]):(?=[/\\])/, '$1')
23
44
  .replace(/[/\\]/g, '-')
@@ -28,6 +49,34 @@ export function memoryDir(cwd: string): string {
28
49
  return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(cwd))
29
50
  }
30
51
 
52
+ /** Move a store written under the pre-digest slug to the current one, once. Without
53
+ * this the slug change would silently orphan every memory a user already has. */
54
+ export function migrateLegacyStore(cwd: string): void {
55
+ const current = memoryDir(cwd)
56
+ if (fs.existsSync(current)) return
57
+ const legacy = path.join(os.homedir(), '.pi', 'agent', 'memory', legacySlug(cwd))
58
+ if (!fs.existsSync(legacy)) return
59
+ try {
60
+ fs.renameSync(legacy, current)
61
+ } catch {
62
+ // A failed migration must not take down session start; the store stays legacy.
63
+ }
64
+ }
65
+
66
+ /** The index as injected into the prompt, bounded like Claude's startup load. */
67
+ export function capIndexForPrompt(index: string): string {
68
+ const withinLines = index.split('\n').slice(0, INDEX_MAX_LINES)
69
+ let dropped = index.split('\n').length - withinLines.length
70
+ let text = withinLines.join('\n')
71
+ while (Buffer.byteLength(text, 'utf-8') > INDEX_MAX_BYTES && withinLines.length > 1) {
72
+ withinLines.pop()
73
+ dropped++
74
+ text = withinLines.join('\n')
75
+ }
76
+ if (dropped <= 0) return index
77
+ return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
78
+ }
79
+
31
80
  export function slugifyName(name: string): string {
32
81
  return (
33
82
  name
@@ -76,6 +125,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
76
125
  let dir = memoryDir(process.cwd())
77
126
 
78
127
  pi.on('session_start', async (_event, ctx) => {
128
+ migrateLegacyStore(ctx.cwd)
79
129
  dir = memoryDir(ctx.cwd)
80
130
  const count = readIndex(dir)
81
131
  .split('\n')
@@ -87,7 +137,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
87
137
  const index = readIndex(dir)
88
138
  if (!index.trim()) return
89
139
  return {
90
- systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${index}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
140
+ systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
91
141
  }
92
142
  })
93
143
 
@@ -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
@@ -194,8 +194,10 @@ export function cleanStepText(text: string): string {
194
194
  // Anchored to line start (m flag) so a prose line merely ending in "plan:" is not taken
195
195
  // for the header, which would slice the plan section mid-list and drop earlier steps.
196
196
  // Horizontal whitespace only ([^\S\n]): \s would include \n itself and overlap the
197
- // following \n, which is what backtracks super-linearly.
198
- const PLAN_HEADER = /^[^\S\n]*\*{0,2}Plan:\*{0,2}[^\S\n]*\n/im
197
+ // following \n. The runs are bounded rather than unbounded: an unbounded run retried
198
+ // from every position on a long whitespace-only line is what backtracks super-linearly,
199
+ // and a real header carries at most a few spaces of indentation.
200
+ const PLAN_HEADER = /^[^\S\n]{0,8}\*{0,2}Plan:\*{0,2}[^\S\n]{0,8}\n/im
199
201
 
200
202
  const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
201
203
 
@@ -32,10 +32,10 @@ const OptionSchema = Type.Object({
32
32
  description: Type.Optional(Type.String({ description: 'Optional description shown below label' })),
33
33
  })
34
34
 
35
- const QuestionParams = Type.Object({
35
+ export const QuestionParams = Type.Object({
36
36
  question: Type.String({ description: 'The question to ask the user' }),
37
- header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it' })),
38
- options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
37
+ header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it (max 12 characters)', maxLength: 12 })),
38
+ options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (1-4)', minItems: 1, maxItems: 4 }),
39
39
  multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
40
40
  })
41
41
 
@@ -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
  }
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
7
7
  - **Isolated context**: Each subagent runs in a separate `pi` process
8
8
  - **Streaming output**: See tool calls and progress as they happen
9
9
  - **Parallel streaming**: All parallel tasks stream updates simultaneously
10
- - **Background runs**: Fire-and-forget with a completion notification; max 8 running at once
10
+ - **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs and `{cancel: "<id>"}` stops one (signalling its process group); max 8 running at once
11
11
  - **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
12
12
  - **Markdown rendering**: Final output rendered with proper formatting (expanded view)
13
13
  - **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
@@ -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
 
@@ -12,10 +12,12 @@ export interface BackgroundRun {
12
12
  id: string
13
13
  agent: string
14
14
  task: string
15
- state: 'running' | 'done' | 'failed'
15
+ state: 'running' | 'done' | 'failed' | 'cancelled'
16
16
  exitCode?: number
17
17
  output?: string
18
18
  turns: number
19
+ /** Set while running so the run can be cancelled; cleared on completion. */
20
+ kill?: () => void
19
21
  }
20
22
 
21
23
  export interface BackgroundSpawn {
@@ -63,6 +65,18 @@ export function formatStatus(all: Iterable<BackgroundRun>): string {
63
65
  return lines.length > 0 ? lines.join('\n') : 'No background runs in this session.'
64
66
  }
65
67
 
68
+ /** Cancel a running background child. Returns what the caller should tell the model:
69
+ * unknown id, already finished, or cancelled. */
70
+ export function cancelBackgroundRun(id: string): 'cancelled' | 'not-running' | 'unknown' {
71
+ const run = runs.get(id)
72
+ if (!run) return 'unknown'
73
+ if (run.state !== 'running' || !run.kill) return 'not-running'
74
+ run.state = 'cancelled'
75
+ run.kill()
76
+ run.kill = undefined
77
+ return 'cancelled'
78
+ }
79
+
66
80
  export function backgroundStatusText(): string {
67
81
  return formatStatus(runs.values())
68
82
  }
@@ -80,9 +94,18 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
80
94
  cwd: invocation.cwd,
81
95
  shell: false,
82
96
  stdio: ['ignore', 'pipe', 'ignore'],
97
+ // Its own group, so cancelling reaches any grandchild the agent spawned.
98
+ detached: true,
83
99
  // The marker lets the child's subagent tool refuse to nest further.
84
100
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
85
101
  })
102
+ run.kill = () => {
103
+ try {
104
+ process.kill(-proc.pid!, 'SIGTERM')
105
+ } catch {
106
+ proc.kill('SIGTERM')
107
+ }
108
+ }
86
109
  let stdout = ''
87
110
  // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
88
111
  let completed = false
@@ -96,13 +119,16 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
96
119
  })
97
120
  proc.on('close', (code) => {
98
121
  const { text, turns } = parseFinalOutputFromJsonl(stdout)
99
- run.state = code === 0 ? 'done' : 'failed'
122
+ run.kill = undefined
123
+ // A cancelled run keeps that state: its non-zero exit is the cancellation.
124
+ if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
100
125
  run.exitCode = code ?? 0
101
126
  run.output = text
102
127
  run.turns = turns
103
128
  complete()
104
129
  })
105
130
  proc.on('error', () => {
131
+ run.kill = undefined
106
132
  run.state = 'failed'
107
133
  run.exitCode = 1
108
134
  complete()
@@ -24,10 +24,10 @@ 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
- import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
30
+ import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
31
31
 
32
32
  const MAX_PARALLEL_TASKS = 8
33
33
  const MAX_CONCURRENCY = 4
@@ -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[]
@@ -460,6 +460,7 @@ const SubagentParams = Type.Object({
460
460
  cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process (single mode)' })),
461
461
  background: Type.Optional(Type.Boolean({ description: 'Run the single-mode task in the background: returns a run id immediately and a notification arrives when it completes.' })),
462
462
  status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
463
+ cancel: Type.Optional(Type.String({ description: 'Background run id to cancel (from the id returned when it started, or from status).' })),
463
464
  })
464
465
 
465
466
  /**
@@ -489,6 +490,14 @@ type SubagentParamsStatic = Static<typeof SubagentParams>
489
490
  type ChainStepParam = Static<typeof ChainItem>
490
491
  type TaskItemParam = Static<typeof TaskItem>
491
492
 
493
+ /** What to tell the model about a cancel request. */
494
+ export function cancelResultText(id: string): string {
495
+ const outcome = cancelBackgroundRun(id)
496
+ if (outcome === 'cancelled') return `Cancelled background run ${id}.`
497
+ if (outcome === 'not-running') return `Background run ${id} already finished; nothing to cancel.`
498
+ return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
499
+ }
500
+
492
501
  /** Everything a mode handler needs from the surrounding execute() call. */
493
502
  interface ModeContext {
494
503
  agents: AgentConfig[]
@@ -1068,6 +1077,19 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1068
1077
  }
1069
1078
 
1070
1079
  export default function subagentExtension(pi: ExtensionAPI) {
1080
+ // Claude surfaces each agent's description so the model can pick one autonomously.
1081
+ // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1082
+ // included only when the project is already approved, read without prompting, since
1083
+ // a trust dialog must not appear mid-turn and their descriptions are project text.
1084
+ pi.on('before_agent_start', async (event, ctx) => {
1085
+ const scope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1086
+ const { agents } = discoverAgents(ctx.cwd, scope)
1087
+ if (agents.length === 0) return
1088
+ const line = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 200)
1089
+ const roster = agents.map((agent) => `- ${agent.name} (${agent.source}): ${line(agent.description)}`).join('\n')
1090
+ return { systemPrompt: `${event.systemPrompt}\n\n## Subagents\n\nDelegate isolated tasks with the subagent tool ({agent, task}). Available agents:\n${roster}` }
1091
+ })
1092
+
1071
1093
  pi.registerTool({
1072
1094
  name: 'subagent',
1073
1095
  label: 'Subagent',
@@ -1107,6 +1129,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
1107
1129
  results,
1108
1130
  })
1109
1131
 
1132
+ if (params.cancel) {
1133
+ return { content: [{ type: 'text', text: cancelResultText(params.cancel) }], details: makeDetails('single')([]) }
1134
+ }
1135
+
1110
1136
  if (params.status) {
1111
1137
  return { content: [{ type: 'text', text: backgroundStatusText() }], details: makeDetails('single')([]) }
1112
1138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.