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 +4 -4
- package/extensions/git-checkpoint.ts +33 -1
- package/extensions/internal/builtin-styles/explanatory.md +15 -0
- package/extensions/internal/builtin-styles/learning.md +15 -0
- package/extensions/internal/builtin-styles/proactive.md +9 -0
- package/extensions/internal/project-approval.ts +10 -0
- package/extensions/mcp.ts +96 -10
- package/extensions/memory.ts +51 -1
- package/extensions/output-styles.ts +43 -7
- package/extensions/plan-mode/utils.ts +4 -2
- package/extensions/question.ts +3 -3
- package/extensions/status-line.ts +154 -12
- package/extensions/subagent/README.md +16 -35
- package/extensions/subagent/agents/explore.md +18 -0
- package/extensions/subagent/agents/general-purpose.md +10 -0
- package/extensions/subagent/agents/plan.md +16 -0
- package/extensions/subagent/agents.ts +9 -4
- package/extensions/subagent/background.ts +28 -2
- package/extensions/subagent/index.ts +29 -3
- package/package.json +1 -1
- package/extensions/subagent/agents/planner.md +0 -37
- package/extensions/subagent/agents/reviewer.md +0 -35
- package/extensions/subagent/agents/scout.md +0 -50
- package/extensions/subagent/agents/worker.md +0 -24
- package/extensions/subagent/prompts/implement-and-review.md +0 -10
- package/extensions/subagent/prompts/implement.md +0 -10
- package/extensions/subagent/prompts/scout-and-plan.md +0 -9
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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:
|
|
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 (
|
|
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
|
|
198
|
-
|
|
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
|
|
package/extensions/question.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
162
|
+
// One instance serves every session, so a fresh session must not inherit state.
|
|
44
163
|
turnCount = 0
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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**:
|
|
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/ #
|
|
25
|
-
│ ├──
|
|
26
|
-
│ ├──
|
|
27
|
-
│
|
|
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:**
|
|
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
|
|
50
|
+
Use Explore to find all authentication code
|
|
56
51
|
```
|
|
57
52
|
|
|
58
53
|
### Parallel execution
|
|
59
54
|
```
|
|
60
|
-
Run 2
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|