pi-code 0.2.4 → 0.3.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 +7 -6
- package/extensions/claude-rules.ts +86 -32
- package/extensions/context-imports.ts +57 -17
- package/extensions/hooks.ts +46 -11
- package/extensions/{project-approval.ts → internal/project-approval.ts} +12 -1
- package/extensions/mcp.ts +29 -7
- package/extensions/memory.ts +9 -4
- package/extensions/notify.ts +2 -0
- package/extensions/output-styles.ts +12 -3
- package/extensions/plan-mode/README.md +1 -1
- package/extensions/plan-mode/index.ts +4 -0
- package/extensions/subagent/README.md +20 -26
- package/extensions/subagent/agents.ts +77 -20
- package/extensions/subagent/background.ts +17 -3
- package/extensions/subagent/index.ts +68 -31
- package/extensions/web.ts +17 -9
- package/package.json +1 -1
- /package/extensions/{output-guard.ts → internal/output-guard.ts} +0 -0
- /package/extensions/{web-transport.ts → internal/web-transport.ts} +0 -0
package/README.md
CHANGED
|
@@ -37,16 +37,16 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
|
|
39
39
|
| Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
|
|
40
|
-
| Skills | `.claude/skills` → pi skill discovery | `skills.ts` |
|
|
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 on pi lifecycle events | `hooks.ts` |
|
|
42
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
43
|
-
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
|
|
43
|
+
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
|
|
44
44
|
| MCP servers | user `~/.claude.json`, `~/.pi/agent/mcp.json` (loaded on session start); project `.mcp.json`, `.pi/mcp.json` (only once the project is approved); stdio, HTTP, SSE | `mcp.ts` |
|
|
45
|
-
| Project trust | prompts before loading project config (MCP servers, hooks, agents) that pi would otherwise trust silently | `project-approval.ts` |
|
|
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
46
|
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; 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
|
-
| Checkpoints / rewind | shadow-repo snapshots,
|
|
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 | vendored example | `question.ts` |
|
|
@@ -55,7 +55,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
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.
|
|
57
57
|
|
|
58
|
-
`output-guard.ts`
|
|
58
|
+
`extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), and `project-approval.ts` (the trust decision above). The extensions use them; only `internal/` keeps them out of pi's extension scan.
|
|
59
59
|
|
|
60
60
|
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
61
61
|
|
|
@@ -64,7 +64,8 @@ Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example
|
|
|
64
64
|
```bash
|
|
65
65
|
npm install
|
|
66
66
|
npm run check # biome + strict tsc + vitest, the whole gate
|
|
67
|
-
scripts/e2e.sh #
|
|
67
|
+
scripts/e2e.sh # quick smoke of the real pi TUI via tmux (needs a working model)
|
|
68
|
+
scripts/e2e-full.sh # every README feature end to end, model turns included (5-15 min)
|
|
68
69
|
scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
|
|
69
70
|
```
|
|
70
71
|
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Claude Rules Extension
|
|
3
3
|
*
|
|
4
4
|
* Replicates Claude Code's rules loading:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
5
|
+
* - Unscoped global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
|
|
6
|
+
* - Path-scoped global rules and all project rules (.claude/rules/*.md) are listed as
|
|
7
|
+
* pointers the agent reads on demand.
|
|
7
8
|
*
|
|
8
9
|
* Path-scoped rules: a rule file may declare `paths:` frontmatter (a glob or
|
|
9
|
-
* list of globs).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* list of globs). Pointers surface that scope so the agent knows to read the
|
|
11
|
+
* rule when working on matching files. Frontmatter is stripped from inlined
|
|
12
|
+
* global rules.
|
|
12
13
|
*
|
|
13
14
|
* Adapted from the pi v0.74.2 claude-rules example.
|
|
14
15
|
*/
|
|
@@ -18,6 +19,8 @@ import * as os from 'node:os'
|
|
|
18
19
|
import * as path from 'node:path'
|
|
19
20
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
21
|
|
|
22
|
+
import { isProjectApproved } from './internal/project-approval.js'
|
|
23
|
+
|
|
21
24
|
export interface Frontmatter {
|
|
22
25
|
paths: string[]
|
|
23
26
|
body: string
|
|
@@ -61,39 +64,82 @@ export function parseFrontmatter(content: string): Frontmatter {
|
|
|
61
64
|
return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
/** A
|
|
65
|
-
export function formatRulePointer(rel: string, paths: string[]): string {
|
|
66
|
-
const ref = `-
|
|
67
|
+
/** A rule pointer line, annotated with its path scope when present. */
|
|
68
|
+
export function formatRulePointer(rel: string, paths: string[], base = '.claude/rules'): string {
|
|
69
|
+
const ref = `- ${base}/${rel}`
|
|
67
70
|
return paths.length > 0 ? `${ref} — applies when working on: ${paths.join(', ')}` : ref
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
if (!
|
|
73
|
+
/** A dirent's kind with symlinks resolved; nulls a dangling link. */
|
|
74
|
+
function classifyEntry(entry: fs.Dirent, fullPath: string): { isDir: boolean; isFile: boolean } | null {
|
|
75
|
+
if (!entry.isSymbolicLink()) return { isDir: entry.isDirectory(), isFile: entry.isFile() }
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.statSync(fullPath)
|
|
78
|
+
return { isDir: stat.isDirectory(), isFile: stat.isFile() }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Recursively find all .md files, following symlinks (Claude Code documents symlinked
|
|
85
|
+
* shared rule dirs); `visited` realpaths keep a circular link from recursing forever. */
|
|
86
|
+
function findMarkdownFiles(dir: string, basePath = '', visited = new Set<string>()): string[] {
|
|
73
87
|
const results: string[] = []
|
|
74
|
-
|
|
88
|
+
try {
|
|
89
|
+
const real = fs.realpathSync(dir)
|
|
90
|
+
if (visited.has(real)) return results
|
|
91
|
+
visited.add(real)
|
|
92
|
+
} catch {
|
|
93
|
+
return results
|
|
94
|
+
}
|
|
95
|
+
let entries: fs.Dirent[]
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
98
|
+
} catch {
|
|
99
|
+
return results // a missing or unreadable directory must not take down session start
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
75
102
|
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
const fullPath = path.join(dir, entry.name)
|
|
104
|
+
const kind = classifyEntry(entry, fullPath)
|
|
105
|
+
if (!kind) continue
|
|
106
|
+
if (kind.isDir) {
|
|
107
|
+
results.push(...findMarkdownFiles(fullPath, relativePath, visited))
|
|
108
|
+
} else if (kind.isFile && entry.name.endsWith('.md')) {
|
|
79
109
|
results.push(relativePath)
|
|
80
110
|
}
|
|
81
111
|
}
|
|
82
112
|
return results
|
|
83
113
|
}
|
|
84
114
|
|
|
85
|
-
function readGlobalRules(globalRulesDir: string): string {
|
|
86
|
-
return findMarkdownFiles(globalRulesDir)
|
|
87
|
-
.map((file) => parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8')).body.trim())
|
|
88
|
-
.filter((content) => content.length > 0)
|
|
89
|
-
.join('\n\n')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
115
|
interface ProjectRule {
|
|
93
116
|
rel: string
|
|
94
117
|
paths: string[]
|
|
95
118
|
}
|
|
96
119
|
|
|
120
|
+
interface GlobalRules {
|
|
121
|
+
inline: string
|
|
122
|
+
scoped: ProjectRule[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Unscoped global rules are inlined; path-scoped ones keep their scope as pointers,
|
|
126
|
+
* mirroring Claude Code, where scoped rules attach only to matching files. */
|
|
127
|
+
function readGlobalRules(globalRulesDir: string): GlobalRules {
|
|
128
|
+
const inline: string[] = []
|
|
129
|
+
const scoped: ProjectRule[] = []
|
|
130
|
+
for (const file of findMarkdownFiles(globalRulesDir)) {
|
|
131
|
+
let parsed: Frontmatter
|
|
132
|
+
try {
|
|
133
|
+
parsed = parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8'))
|
|
134
|
+
} catch {
|
|
135
|
+
continue // one unreadable rule must not take down session start
|
|
136
|
+
}
|
|
137
|
+
if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths })
|
|
138
|
+
else if (parsed.body.trim().length > 0) inline.push(parsed.body.trim())
|
|
139
|
+
}
|
|
140
|
+
return { inline: inline.join('\n\n'), scoped }
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
function readProjectRules(projectRulesDir: string): ProjectRule[] {
|
|
98
144
|
return findMarkdownFiles(projectRulesDir).map((rel) => {
|
|
99
145
|
try {
|
|
@@ -106,26 +152,34 @@ function readProjectRules(projectRulesDir: string): ProjectRule[] {
|
|
|
106
152
|
|
|
107
153
|
export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
108
154
|
const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
|
|
109
|
-
let globalRules = ''
|
|
155
|
+
let globalRules: GlobalRules = { inline: '', scoped: [] }
|
|
110
156
|
let projectRules: ProjectRule[] = []
|
|
111
157
|
|
|
112
158
|
pi.on('session_start', async (_event, ctx) => {
|
|
113
159
|
globalRules = readGlobalRules(globalRulesDir)
|
|
114
|
-
// Project rule filenames and their paths: frontmatter are surfaced in the system prompt
|
|
115
|
-
//
|
|
116
|
-
const
|
|
117
|
-
projectRules =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
160
|
+
// Project rule filenames and their paths: frontmatter are surfaced in the system prompt.
|
|
161
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
162
|
+
const approved = await isProjectApproved(ctx)
|
|
163
|
+
projectRules = approved ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
|
|
164
|
+
|
|
165
|
+
const hasGlobal = globalRules.inline.length > 0 || globalRules.scoped.length > 0
|
|
166
|
+
if (hasGlobal || projectRules.length > 0) {
|
|
167
|
+
ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
|
|
121
168
|
}
|
|
122
169
|
})
|
|
123
170
|
|
|
124
171
|
pi.on('before_agent_start', async (event) => {
|
|
125
172
|
let addition = ''
|
|
126
173
|
|
|
127
|
-
if (globalRules.length > 0) {
|
|
128
|
-
addition += `\n\n## Global Rules
|
|
174
|
+
if (globalRules.inline.length > 0 || globalRules.scoped.length > 0) {
|
|
175
|
+
addition += `\n\n## Global Rules`
|
|
176
|
+
if (globalRules.inline.length > 0) {
|
|
177
|
+
addition += `\n\nThese rules always apply:\n\n${globalRules.inline}`
|
|
178
|
+
}
|
|
179
|
+
if (globalRules.scoped.length > 0) {
|
|
180
|
+
const scopedList = globalRules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, '~/.claude/rules')).join('\n')
|
|
181
|
+
addition += `\n\nPath-scoped global rules, available in ~/.claude/rules/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
|
|
182
|
+
}
|
|
129
183
|
}
|
|
130
184
|
|
|
131
185
|
if (projectRules.length > 0) {
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Context Imports Extension
|
|
3
3
|
*
|
|
4
4
|
* pi loads CLAUDE.md / AGENTS.md context files natively but does not resolve
|
|
5
|
-
* Claude Code's `@path` imports inside them
|
|
6
|
-
* before_agent_start it reads the
|
|
7
|
-
* systemPromptOptions, resolves any `@path`
|
|
8
|
-
* cycle-safe, budget-capped; ~ expands to
|
|
9
|
-
* the importing file), and appends
|
|
10
|
-
*
|
|
5
|
+
* Claude Code's `@path` imports inside them, and skips CLAUDE.local.md
|
|
6
|
+
* entirely. This fills both gaps: on before_agent_start it reads the
|
|
7
|
+
* already-loaded context files from systemPromptOptions, resolves any `@path`
|
|
8
|
+
* imports (recursive, depth-capped, cycle-safe, budget-capped; ~ expands to
|
|
9
|
+
* home, relative paths resolve against the importing file), and appends the
|
|
10
|
+
* imported content plus the approval-gated CLAUDE.local.md body. The base
|
|
11
|
+
* files pi already injected are never re-appended.
|
|
11
12
|
*
|
|
12
13
|
* Security: context files can come from an untrusted project, so imports are
|
|
13
14
|
* confined (after resolving symlinks) to the working directory and the user's
|
|
@@ -26,6 +27,8 @@ import * as os from 'node:os'
|
|
|
26
27
|
import * as path from 'node:path'
|
|
27
28
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
28
29
|
|
|
30
|
+
import { isProjectApproved } from './internal/project-approval.js'
|
|
31
|
+
|
|
29
32
|
const MAX_IMPORT_DEPTH = 5
|
|
30
33
|
export const MAX_IMPORT_FILES = 50
|
|
31
34
|
export const MAX_IMPORT_BYTES = 256 * 1024
|
|
@@ -70,17 +73,29 @@ export interface ImportBudget {
|
|
|
70
73
|
|
|
71
74
|
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
function fenceMarker(lineStart: string): string | null {
|
|
77
|
+
if (lineStart.startsWith('```')) return '`'
|
|
78
|
+
if (lineStart.startsWith('~~~')) return '~'
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `@path` targets of a context file, in document order. Claude Code evaluates
|
|
83
|
+
* imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
|
|
74
84
|
function importTargets(content: string): string[] {
|
|
75
85
|
const targets: string[] = []
|
|
76
|
-
|
|
86
|
+
// A fence only closes with the character that opened it: a backtick-fenced
|
|
87
|
+
// example may legitimately contain tilde-fence lines, and vice versa.
|
|
88
|
+
let fence: string | null = null
|
|
77
89
|
for (const line of content.split('\n')) {
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
const marker = fenceMarker(line.trimStart())
|
|
91
|
+
if (marker !== null && (fence === null || fence === marker)) {
|
|
92
|
+
fence = fence === null ? marker : null
|
|
80
93
|
continue
|
|
81
94
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
95
|
+
if (fence !== null) continue
|
|
96
|
+
// Backreference so a multi-backtick span (``literal `@x` backticks``) strips whole.
|
|
97
|
+
const withoutSpans = line.replace(/(`+)[^`]*?\1/g, '')
|
|
98
|
+
for (const match of withoutSpans.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
|
|
84
99
|
}
|
|
85
100
|
return targets
|
|
86
101
|
}
|
|
@@ -146,8 +161,25 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
|
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
164
|
+
let localContext: { path: string; content: string } | null = null
|
|
165
|
+
|
|
166
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
167
|
+
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
168
|
+
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
169
|
+
localContext = null
|
|
170
|
+
const candidate = path.join(ctx.cwd, 'CLAUDE.local.md')
|
|
171
|
+
if (!fs.existsSync(candidate)) return
|
|
172
|
+
if (!(await isProjectApproved(ctx))) return
|
|
173
|
+
try {
|
|
174
|
+
localContext = { path: candidate, content: fs.readFileSync(candidate, 'utf-8') }
|
|
175
|
+
} catch {
|
|
176
|
+
// unreadable: treat as absent
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
149
180
|
pi.on('before_agent_start', async (event) => {
|
|
150
|
-
const contextFiles: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
181
|
+
const contextFiles: Array<{ path: string; content: string }> = [...(event.systemPromptOptions?.contextFiles ?? [])]
|
|
182
|
+
if (localContext) contextFiles.push(localContext)
|
|
151
183
|
if (contextFiles.length === 0) return
|
|
152
184
|
|
|
153
185
|
const home = os.homedir()
|
|
@@ -164,10 +196,18 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
164
196
|
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
165
197
|
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
|
|
166
198
|
}
|
|
167
|
-
if (imported.length === 0) return
|
|
168
199
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
let addition = ''
|
|
201
|
+
if (localContext && localContext.content.trim().length > 0) {
|
|
202
|
+
addition += `\n\n## CLAUDE.local.md\n\n${localContext.content.trim()}`
|
|
203
|
+
}
|
|
204
|
+
if (imported.length > 0) {
|
|
205
|
+
const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
|
|
206
|
+
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
207
|
+
addition += `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
208
|
+
}
|
|
209
|
+
if (addition.length === 0) return
|
|
210
|
+
|
|
211
|
+
return { systemPrompt: event.systemPrompt + addition }
|
|
172
212
|
})
|
|
173
213
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -27,7 +27,7 @@ import * as os from 'node:os'
|
|
|
27
27
|
import * as path from 'node:path'
|
|
28
28
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
29
29
|
|
|
30
|
-
import { isProjectApproved } from './project-approval.js'
|
|
30
|
+
import { isProjectApproved } from './internal/project-approval.js'
|
|
31
31
|
|
|
32
32
|
const DEFAULT_TIMEOUT_S = 60
|
|
33
33
|
|
|
@@ -53,7 +53,7 @@ export interface HookRunResult {
|
|
|
53
53
|
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
54
54
|
timedOut: boolean
|
|
55
55
|
}
|
|
56
|
-
export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
56
|
+
export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
|
|
57
57
|
|
|
58
58
|
/** Settings files to read, newest-winning. Project files load only when trusted. */
|
|
59
59
|
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
@@ -87,11 +87,17 @@ function matcherApplies(matcher: string | undefined, name: string): boolean {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Claude settings may carry prompt/agent hook types with no command; running one
|
|
91
|
+
* through `sh -c undefined` would throw out of the tool_call handler. */
|
|
92
|
+
function isRunnableHook(hook: HookCommand): boolean {
|
|
93
|
+
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
/** Command specs whose matcher applies to the given tool/source name. */
|
|
91
97
|
export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
|
|
92
98
|
const result: HookCommand[] = []
|
|
93
99
|
for (const entry of matchers ?? []) {
|
|
94
|
-
if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []))
|
|
100
|
+
if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []).filter(isRunnableHook))
|
|
95
101
|
}
|
|
96
102
|
return result
|
|
97
103
|
}
|
|
@@ -137,12 +143,14 @@ function killTree(child: ChildProcess): void {
|
|
|
137
143
|
child.kill('SIGKILL')
|
|
138
144
|
}
|
|
139
145
|
|
|
140
|
-
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
146
|
+
export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectDir) =>
|
|
141
147
|
new Promise((resolve) => {
|
|
142
148
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
143
149
|
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
144
|
-
// the descendants too.
|
|
145
|
-
|
|
150
|
+
// the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
151
|
+
// reference project files regardless of the shell's cwd.
|
|
152
|
+
const env = projectDir ? { ...process.env, CLAUDE_PROJECT_DIR: projectDir } : process.env
|
|
153
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
146
154
|
let stdout = ''
|
|
147
155
|
let stderr = ''
|
|
148
156
|
let settled = false
|
|
@@ -178,8 +186,15 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
|
178
186
|
child.stdin?.end(JSON.stringify(payload))
|
|
179
187
|
})
|
|
180
188
|
|
|
189
|
+
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
190
|
+
const MAX_TIMEOUT_S = 2_147_483
|
|
191
|
+
|
|
181
192
|
function timeoutMs(command: HookCommand): number {
|
|
182
|
-
|
|
193
|
+
// Non-positive values fall back to the default: a 0ms timer would fire before the
|
|
194
|
+
// hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
|
|
195
|
+
const declared = command.timeout
|
|
196
|
+
const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
|
|
197
|
+
return seconds * 1000
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
/** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
|
|
@@ -199,25 +214,45 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
|
|
|
199
214
|
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
200
215
|
}
|
|
201
216
|
|
|
217
|
+
/** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
|
|
218
|
+
const MAX_PENDING_INPUTS = 100
|
|
219
|
+
|
|
202
220
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
203
221
|
let config: HooksConfig = {}
|
|
222
|
+
let projectDir = ''
|
|
223
|
+
// tool_execution_end does not carry the tool's input, but Claude's PostToolUse
|
|
224
|
+
// contract does, so remember it from tool_call keyed by the call id.
|
|
225
|
+
const pendingInputs = new Map<string, unknown>()
|
|
226
|
+
const runner: HookRunner = (command, payload, ms) => runHookCommand(command, payload, ms, projectDir)
|
|
204
227
|
|
|
205
228
|
pi.on('session_start', async (event, ctx) => {
|
|
206
229
|
const trusted = await isProjectApproved(ctx)
|
|
230
|
+
projectDir = ctx.cwd
|
|
207
231
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
208
232
|
// Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
|
|
209
233
|
// "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
|
|
210
234
|
if (event.reason === 'reload' || event.reason === 'fork') return
|
|
211
|
-
await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason },
|
|
235
|
+
await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runner)
|
|
212
236
|
})
|
|
213
237
|
|
|
214
238
|
pi.on('tool_call', async (event) => {
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
pendingInputs.set(event.toolCallId, event.input)
|
|
240
|
+
if (pendingInputs.size > MAX_PENDING_INPUTS) {
|
|
241
|
+
const oldest = pendingInputs.keys().next().value
|
|
242
|
+
if (oldest !== undefined) pendingInputs.delete(oldest)
|
|
243
|
+
}
|
|
244
|
+
const decision = await runPreToolUse(config, event.toolName, event.input, runner)
|
|
245
|
+
if (!decision.block) return undefined
|
|
246
|
+
// pi still emits tool_execution_end (isError) for a blocked call, which also
|
|
247
|
+
// cleans up; deleting here just avoids relying on that host detail.
|
|
248
|
+
pendingInputs.delete(event.toolCallId)
|
|
249
|
+
return { block: true, reason: decision.reason }
|
|
217
250
|
})
|
|
218
251
|
|
|
219
252
|
pi.on('tool_execution_end', async (event) => {
|
|
253
|
+
const toolInput = pendingInputs.get(event.toolCallId)
|
|
254
|
+
pendingInputs.delete(event.toolCallId)
|
|
220
255
|
if (event.isError) return
|
|
221
|
-
await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName },
|
|
256
|
+
await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
|
|
222
257
|
})
|
|
223
258
|
}
|
|
@@ -22,7 +22,18 @@ import * as path from 'node:path'
|
|
|
22
22
|
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
|
|
24
24
|
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
25
|
-
const CLAUDE_SHAPED = [
|
|
25
|
+
const CLAUDE_SHAPED = [
|
|
26
|
+
path.join('.claude', 'settings.json'),
|
|
27
|
+
path.join('.claude', 'settings.local.json'),
|
|
28
|
+
path.join('.claude', 'agents'),
|
|
29
|
+
path.join('.claude', 'hooks'),
|
|
30
|
+
path.join('.claude', 'output-styles'),
|
|
31
|
+
path.join('.claude', 'rules'),
|
|
32
|
+
'CLAUDE.local.md',
|
|
33
|
+
'.mcp.json',
|
|
34
|
+
path.join('.pi', 'mcp.json'),
|
|
35
|
+
path.join('.pi', 'agents'),
|
|
36
|
+
]
|
|
26
37
|
|
|
27
38
|
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
28
39
|
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
package/extensions/mcp.ts
CHANGED
|
@@ -28,8 +28,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
|
|
|
28
28
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
29
29
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
30
30
|
import { Type } from 'typebox'
|
|
31
|
-
import { capForContext } from './output-guard.js'
|
|
32
|
-
import { isProjectApproved } from './project-approval.js'
|
|
31
|
+
import { capForContext } from './internal/output-guard.js'
|
|
32
|
+
import { isProjectApproved } from './internal/project-approval.js'
|
|
33
33
|
|
|
34
34
|
const CONNECT_TIMEOUT_MS = 10_000
|
|
35
35
|
const CALL_TIMEOUT_MS = 120_000
|
|
@@ -41,6 +41,7 @@ const CALL_TIMEOUT_MS = 120_000
|
|
|
41
41
|
const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
|
|
42
42
|
|
|
43
43
|
export interface StdioServerConfig {
|
|
44
|
+
type?: 'stdio'
|
|
44
45
|
command: string
|
|
45
46
|
args?: string[]
|
|
46
47
|
env?: Record<string, string>
|
|
@@ -48,6 +49,7 @@ export interface StdioServerConfig {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export interface HttpServerConfig {
|
|
52
|
+
type?: 'http' | 'streamable-http' | 'sse'
|
|
51
53
|
url: string
|
|
52
54
|
headers?: Record<string, string>
|
|
53
55
|
bearerToken?: string
|
|
@@ -56,8 +58,14 @@ export interface HttpServerConfig {
|
|
|
56
58
|
|
|
57
59
|
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
58
60
|
|
|
61
|
+
/** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
|
|
62
|
+
* shell's `:-`, which substitutes when the variable is unset OR empty. */
|
|
59
63
|
export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
60
|
-
return value.replace(/\$\{(\w+)
|
|
64
|
+
return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (_, name, hasDefault, fallback) => {
|
|
65
|
+
const current = env[name]
|
|
66
|
+
if (hasDefault !== undefined) return current || fallback
|
|
67
|
+
return current ?? ''
|
|
68
|
+
})
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/** User-scoped MCP config (the user's own; safe to load without project trust). */
|
|
@@ -123,7 +131,8 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
|
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
126
|
-
|
|
134
|
+
// An explicit type wins; without one, a command field means stdio.
|
|
135
|
+
return 'command' in config && (config.type === undefined || config.type === 'stdio')
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
@@ -150,8 +159,8 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
150
159
|
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
151
160
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
|
|
152
161
|
const transport = new StdioClientTransport({
|
|
153
|
-
command: config.command,
|
|
154
|
-
args: config.args ?? [],
|
|
162
|
+
command: interpolateEnv(config.command),
|
|
163
|
+
args: (config.args ?? []).map((arg) => interpolateEnv(arg)),
|
|
155
164
|
env,
|
|
156
165
|
cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
|
|
157
166
|
stderr: 'ignore',
|
|
@@ -164,12 +173,18 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
164
173
|
const token = config.bearerToken ?? (config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined)
|
|
165
174
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
166
175
|
const url = new URL(interpolateEnv(config.url))
|
|
176
|
+
if (config.type === 'sse') {
|
|
177
|
+
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
|
|
178
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
|
|
179
|
+
return client
|
|
180
|
+
}
|
|
167
181
|
try {
|
|
168
182
|
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
|
|
169
183
|
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
|
|
170
184
|
return client
|
|
171
185
|
} catch (error) {
|
|
172
|
-
|
|
186
|
+
// An explicitly declared streamable transport must not silently degrade to SSE.
|
|
187
|
+
if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
|
|
173
188
|
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
174
189
|
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
|
|
175
190
|
await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
|
|
@@ -195,6 +210,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
195
210
|
|
|
196
211
|
async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
|
|
197
212
|
for (const [name, config] of Object.entries(servers)) {
|
|
213
|
+
// A later scope must not take the name of a server that already connected: it
|
|
214
|
+
// would evict that client from the map, leaking it at shutdown, and misreport
|
|
215
|
+
// the earlier server's status.
|
|
216
|
+
if (clients.has(name)) {
|
|
217
|
+
console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
198
220
|
try {
|
|
199
221
|
const client = await connect(name, config)
|
|
200
222
|
clients.set(name, client)
|
package/extensions/memory.ts
CHANGED
|
@@ -13,7 +13,7 @@ import * as path from 'node:path'
|
|
|
13
13
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
14
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
15
15
|
import { Type } from 'typebox'
|
|
16
|
-
import { capForContext } from './output-guard.js'
|
|
16
|
+
import { capForContext } from './internal/output-guard.js'
|
|
17
17
|
|
|
18
18
|
const INDEX_FILE = 'MEMORY.md'
|
|
19
19
|
|
|
@@ -35,17 +35,22 @@ export function slugifyName(name: string): string {
|
|
|
35
35
|
)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The exact key prefix of a memory's index line; matching on a substring would also
|
|
39
|
+
* hit another entry whose description merely mentions this memory. */
|
|
40
|
+
const entryPrefix = (name: string): string => `- [${name}](${name}.md):`
|
|
41
|
+
|
|
38
42
|
/** Add or replace this memory's line in the index, keyed by its markdown link target. */
|
|
39
43
|
export function upsertIndexLine(index: string, name: string, description: string): string {
|
|
40
|
-
|
|
41
|
-
const
|
|
44
|
+
// One line per memory: a newline in the description would break line-based matching.
|
|
45
|
+
const line = `${entryPrefix(name)} ${description.replace(/\s+/g, ' ').trim()}`
|
|
46
|
+
const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.startsWith(entryPrefix(name)))
|
|
42
47
|
if (lines.length === 0 || !lines[0].startsWith('#')) lines.unshift('# Memory index')
|
|
43
48
|
lines.push(line)
|
|
44
49
|
return `${lines.join('\n')}\n`
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export function removeIndexLine(index: string, name: string): string {
|
|
48
|
-
const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.
|
|
53
|
+
const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.startsWith(entryPrefix(name)))
|
|
49
54
|
return lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
50
55
|
}
|
|
51
56
|
|
package/extensions/notify.ts
CHANGED
|
@@ -38,6 +38,8 @@ function notifyWindows(title: string, body: string): void {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function notify(title: string, body: string): void {
|
|
41
|
+
// Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
|
|
42
|
+
if (!process.stdout.isTTY) return
|
|
41
43
|
if (process.env.WT_SESSION) {
|
|
42
44
|
notifyWindows(title, body)
|
|
43
45
|
} else if (process.env.KITTY_WINDOW_ID) {
|
|
@@ -19,6 +19,8 @@ import * as os from 'node:os'
|
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
21
21
|
|
|
22
|
+
import { isProjectApproved } from './internal/project-approval.js'
|
|
23
|
+
|
|
22
24
|
export interface OutputStyle {
|
|
23
25
|
name: string
|
|
24
26
|
description: string
|
|
@@ -69,7 +71,13 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
|
|
|
69
71
|
}
|
|
70
72
|
for (const entry of entries) {
|
|
71
73
|
if (!entry.endsWith('.md')) continue
|
|
72
|
-
|
|
74
|
+
let content: string
|
|
75
|
+
try {
|
|
76
|
+
content = fs.readFileSync(path.join(dir, entry), 'utf-8')
|
|
77
|
+
} catch {
|
|
78
|
+
continue // a directory named *.md or an unreadable file must not take down session start
|
|
79
|
+
}
|
|
80
|
+
const style = parseStyle(content, entry.replace(/\.md$/, ''))
|
|
73
81
|
byName.set(style.name, style)
|
|
74
82
|
}
|
|
75
83
|
}
|
|
@@ -121,8 +129,9 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
121
129
|
pi.on('session_start', async (_event, ctx) => {
|
|
122
130
|
const home = os.homedir()
|
|
123
131
|
// A project style body is injected verbatim into the system prompt, so only honor
|
|
124
|
-
// project styles / selection once the project is
|
|
125
|
-
|
|
132
|
+
// project styles / selection once the project is approved. isProjectTrusted alone
|
|
133
|
+
// is true for a repo pi never asked about; see project-approval.
|
|
134
|
+
const trusted = await isProjectApproved(ctx)
|
|
126
135
|
styles = loadStyles(styleDirs(ctx.cwd, home, trusted))
|
|
127
136
|
localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
|
|
128
137
|
activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
|
|
@@ -161,6 +161,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
161
161
|
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
162
162
|
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
163
163
|
} else if (choice === 'Refine the plan') {
|
|
164
|
+
// The refined turn may answer in prose; without this reset agent_end would skip
|
|
165
|
+
// deriveTodosFromProse and re-display the superseded todo list.
|
|
166
|
+
planFromTool = false
|
|
164
167
|
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
165
168
|
if (refinement?.trim()) {
|
|
166
169
|
pi.sendUserMessage(refinement.trim())
|
|
@@ -240,6 +243,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
240
243
|
messages: event.messages.filter((m) => {
|
|
241
244
|
const msg = m as AgentMessage & { customType?: string }
|
|
242
245
|
if (msg.customType === 'plan-mode-context') return false
|
|
246
|
+
if (msg.customType === 'plan-execution-context' && !executionMode) return false
|
|
243
247
|
if (msg.role !== 'user') return true
|
|
244
248
|
|
|
245
249
|
const content = msg.content
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Subagent
|
|
1
|
+
# Subagent Extension
|
|
2
2
|
|
|
3
3
|
Delegate tasks to specialized subagents with isolated context windows.
|
|
4
4
|
|
|
@@ -7,6 +7,8 @@ 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
|
|
11
|
+
- **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
|
|
10
12
|
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
|
|
11
13
|
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
12
14
|
- **Abort support**: Ctrl+C propagates to kill subagent processes
|
|
@@ -18,6 +20,7 @@ subagent/
|
|
|
18
20
|
├── README.md # This file
|
|
19
21
|
├── index.ts # The extension (entry point)
|
|
20
22
|
├── agents.ts # Agent discovery logic
|
|
23
|
+
├── background.ts # Background run registry and spawning
|
|
21
24
|
├── agents/ # Sample agent definitions
|
|
22
25
|
│ ├── scout.md # Fast recon, returns compressed context
|
|
23
26
|
│ ├── planner.md # Creates implementation plans
|
|
@@ -31,26 +34,7 @@ subagent/
|
|
|
31
34
|
|
|
32
35
|
## Installation
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
# Symlink the extension (must be in a subdirectory with index.ts)
|
|
38
|
-
mkdir -p ~/.pi/agent/extensions/subagent
|
|
39
|
-
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
|
|
40
|
-
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts
|
|
41
|
-
|
|
42
|
-
# Symlink agents
|
|
43
|
-
mkdir -p ~/.pi/agent/agents
|
|
44
|
-
for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
|
|
45
|
-
ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
|
|
46
|
-
done
|
|
47
|
-
|
|
48
|
-
# Symlink workflow prompts
|
|
49
|
-
mkdir -p ~/.pi/agent/prompts
|
|
50
|
-
for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
|
|
51
|
-
ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
|
|
52
|
-
done
|
|
53
|
-
```
|
|
37
|
+
Installed with pi-code (`pi install npm:pi-code`); nothing to set up separately.
|
|
54
38
|
|
|
55
39
|
## Security Model
|
|
56
40
|
|
|
@@ -58,9 +42,9 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
|
|
|
58
42
|
|
|
59
43
|
**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
|
|
60
44
|
|
|
61
|
-
**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
|
|
45
|
+
**Default behavior:** Only loads **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`.
|
|
62
46
|
|
|
63
|
-
To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
|
|
47
|
+
To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
|
|
64
48
|
|
|
65
49
|
When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
|
|
66
50
|
|
|
@@ -129,15 +113,25 @@ Agents are markdown files with YAML frontmatter:
|
|
|
129
113
|
name: my-agent
|
|
130
114
|
description: What this agent does
|
|
131
115
|
tools: read, grep, find, ls
|
|
132
|
-
|
|
116
|
+
disallowedTools: write, edit
|
|
117
|
+
model: gpt-oss:20b
|
|
118
|
+
effort: high
|
|
133
119
|
---
|
|
134
120
|
|
|
135
121
|
System prompt for the agent goes here.
|
|
136
122
|
```
|
|
137
123
|
|
|
124
|
+
Claude Code fields map onto pi where a sensible seam exists: `tools` and
|
|
125
|
+
`disallowedTools` (comma string or YAML list) become pi's `--tools` /
|
|
126
|
+
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model;
|
|
127
|
+
`permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
|
|
128
|
+
aliases (`sonnet`, `opus`, `haiku`, `inherit`) run on the session's default
|
|
129
|
+
model. Fields with no pi equivalent are ignored: `skills`, `memory`,
|
|
130
|
+
`mcpServers`, `maxTurns`.
|
|
131
|
+
|
|
138
132
|
**Locations:**
|
|
139
|
-
- `~/.pi/agent/agents/*.md` - User-level (always loaded)
|
|
140
|
-
- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`)
|
|
133
|
+
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
134
|
+
- `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`; `.pi` wins a name conflict)
|
|
141
135
|
|
|
142
136
|
Project agents override user agents with the same name when `agentScope: "both"`.
|
|
143
137
|
|
|
@@ -23,13 +23,88 @@ function normalizeToolName(tool: string): string {
|
|
|
23
23
|
return CLAUDE_TOOL_MAP[lower] ?? lower
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
28
|
+
* list. Anything else returns null: a restriction that failed to parse must not run
|
|
29
|
+
* the agent unrestricted.
|
|
30
|
+
*/
|
|
31
|
+
function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
32
|
+
if (raw === undefined) return undefined
|
|
33
|
+
let items: unknown[]
|
|
34
|
+
if (Array.isArray(raw)) items = raw
|
|
35
|
+
else if (typeof raw === 'string') items = raw.split(',')
|
|
36
|
+
else return null
|
|
37
|
+
if (items.some((item) => typeof item !== 'string')) return null
|
|
38
|
+
const tools = (items as string[]).map((item) => normalizeToolName(item.trim())).filter(Boolean)
|
|
39
|
+
return tools.length > 0 ? tools : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Claude Code model aliases name Anthropic tiers. pi's resolver would partial-match
|
|
44
|
+
* one against an authenticated Anthropic provider, but for every other setup the
|
|
45
|
+
* child exits at boot on an unresolvable --model. Running on the session model
|
|
46
|
+
* (Claude's `inherit`) is the degradation that works everywhere; users who want a
|
|
47
|
+
* tier pinned should name a concrete model id.
|
|
48
|
+
*/
|
|
49
|
+
const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
|
|
50
|
+
|
|
51
|
+
function parseModelField(raw: unknown): string | undefined {
|
|
52
|
+
if (typeof raw !== 'string') return undefined
|
|
53
|
+
const model = raw.trim()
|
|
54
|
+
return model && !CLAUDE_MODEL_ALIASES.has(model.toLowerCase()) ? model : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
|
|
58
|
+
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
59
|
+
|
|
60
|
+
function parseEffortField(raw: unknown): string | undefined {
|
|
61
|
+
if (typeof raw !== 'string') return undefined
|
|
62
|
+
const effort = raw.trim().toLowerCase()
|
|
63
|
+
return THINKING_LEVELS.has(effort) ? effort : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** permissionMode has no pi equivalent; 'plan' means a research agent, so translate
|
|
67
|
+
* the intent into a read-only toolset unless the file pins tools itself. */
|
|
68
|
+
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
|
|
69
|
+
|
|
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 {
|
|
72
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string }
|
|
73
|
+
try {
|
|
74
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content)
|
|
75
|
+
} catch {
|
|
76
|
+
return null // malformed YAML must not abort discovery for the whole directory
|
|
77
|
+
}
|
|
78
|
+
const { frontmatter, body } = parsed
|
|
79
|
+
const name = typeof frontmatter.name === 'string' ? frontmatter.name : ''
|
|
80
|
+
const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
|
|
81
|
+
if (!name || !description) return null
|
|
82
|
+
const tools = parseToolsField(frontmatter.tools)
|
|
83
|
+
if (tools === null) return null
|
|
84
|
+
const disallowedTools = parseToolsField(frontmatter.disallowedTools)
|
|
85
|
+
if (disallowedTools === null) return null
|
|
86
|
+
return {
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
tools: tools ?? (frontmatter.permissionMode === 'plan' ? [...READ_ONLY_TOOLS] : undefined),
|
|
90
|
+
disallowedTools,
|
|
91
|
+
model: parseModelField(frontmatter.model),
|
|
92
|
+
effort: parseEffortField(frontmatter.effort),
|
|
93
|
+
systemPrompt: body,
|
|
94
|
+
source,
|
|
95
|
+
filePath,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
26
99
|
export type AgentScope = 'user' | 'project' | 'both'
|
|
27
100
|
|
|
28
101
|
export interface AgentConfig {
|
|
29
102
|
name: string
|
|
30
103
|
description: string
|
|
31
104
|
tools?: string[]
|
|
105
|
+
disallowedTools?: string[]
|
|
32
106
|
model?: string
|
|
107
|
+
effort?: string
|
|
33
108
|
systemPrompt: string
|
|
34
109
|
source: 'user' | 'project'
|
|
35
110
|
filePath: string
|
|
@@ -66,26 +141,8 @@ function loadAgentsFromDir(dir: string, source: 'user' | 'project'): AgentConfig
|
|
|
66
141
|
continue
|
|
67
142
|
}
|
|
68
143
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
if (!frontmatter.name || !frontmatter.description) {
|
|
72
|
-
continue
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const tools = frontmatter.tools
|
|
76
|
-
?.split(',')
|
|
77
|
-
.map((t: string) => normalizeToolName(t.trim()))
|
|
78
|
-
.filter(Boolean)
|
|
79
|
-
|
|
80
|
-
agents.push({
|
|
81
|
-
name: frontmatter.name,
|
|
82
|
-
description: frontmatter.description,
|
|
83
|
-
tools: tools && tools.length > 0 ? tools : undefined,
|
|
84
|
-
model: frontmatter.model,
|
|
85
|
-
systemPrompt: body,
|
|
86
|
-
source,
|
|
87
|
-
filePath,
|
|
88
|
-
})
|
|
144
|
+
const agent = parseAgentFile(content, source, filePath)
|
|
145
|
+
if (agent) agents.push(agent)
|
|
89
146
|
}
|
|
90
147
|
|
|
91
148
|
return agents
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background subagent runs: fire-and-forget children whose completion wakes
|
|
3
|
-
* the parent agent via a notification message.
|
|
4
|
-
*
|
|
3
|
+
* the parent agent via a notification message. State lives in an in-memory
|
|
4
|
+
* registry queried via {status: true}; it is lost on restart, and a child
|
|
5
|
+
* still running when pi exits finishes on its own rather than being killed.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { spawn } from 'node:child_process'
|
|
@@ -25,6 +26,13 @@ export interface BackgroundSpawn {
|
|
|
25
26
|
|
|
26
27
|
const runs = new Map<string, BackgroundRun>()
|
|
27
28
|
|
|
29
|
+
/** Cap on simultaneously running background children. */
|
|
30
|
+
export const MAX_BACKGROUND_RUNS = 8
|
|
31
|
+
|
|
32
|
+
export function activeBackgroundRuns(): number {
|
|
33
|
+
return [...runs.values()].filter((run) => run.state === 'running').length
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
/** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
|
|
29
37
|
export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
|
|
30
38
|
let text = ''
|
|
@@ -58,7 +66,11 @@ export function backgroundStatusText(): string {
|
|
|
58
66
|
return formatStatus(runs.values())
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string {
|
|
69
|
+
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
70
|
+
// Checked here, synchronously with registration: callers await temp-file writes
|
|
71
|
+
// between any check of their own and this call, so a parallel tool-call batch
|
|
72
|
+
// could otherwise all pass that earlier check and overshoot the cap.
|
|
73
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
62
74
|
const id = `bg-${randomUUID().slice(0, 8)}`
|
|
63
75
|
const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0 }
|
|
64
76
|
runs.set(id, run)
|
|
@@ -67,6 +79,8 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
67
79
|
cwd: invocation.cwd,
|
|
68
80
|
shell: false,
|
|
69
81
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
82
|
+
// The marker lets the child's subagent tool refuse to nest further.
|
|
83
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
70
84
|
})
|
|
71
85
|
let stdout = ''
|
|
72
86
|
proc.stdout.on('data', (data) => {
|
|
@@ -22,9 +22,10 @@ import { StringEnum } from '@earendil-works/pi-ai'
|
|
|
22
22
|
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
24
24
|
import { type Static, Type } from 'typebox'
|
|
25
|
-
import {
|
|
25
|
+
import { capForContext } from '../internal/output-guard.js'
|
|
26
|
+
import { isProjectApproved } from '../internal/project-approval.js'
|
|
26
27
|
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
27
|
-
import { backgroundStatusText, startBackgroundRun } from './background.js'
|
|
28
|
+
import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
|
|
28
29
|
|
|
29
30
|
const MAX_PARALLEL_TASKS = 8
|
|
30
31
|
const MAX_CONCURRENCY = 4
|
|
@@ -274,9 +275,7 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
const args
|
|
278
|
-
if (agent.model) args.push('--model', agent.model)
|
|
279
|
-
if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
|
|
278
|
+
const args = agentInvocationArgs(agent)
|
|
280
279
|
|
|
281
280
|
let tmpPromptDir: string | null = null
|
|
282
281
|
let tmpPromptPath: string | null = null
|
|
@@ -319,6 +318,8 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
|
319
318
|
cwd: cwd ?? defaultCwd,
|
|
320
319
|
shell: false,
|
|
321
320
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
321
|
+
// The marker lets the child's subagent tool refuse to nest further.
|
|
322
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
322
323
|
})
|
|
323
324
|
let buffer = ''
|
|
324
325
|
|
|
@@ -504,6 +505,38 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
504
505
|
return null
|
|
505
506
|
}
|
|
506
507
|
|
|
508
|
+
/** CLI args shared by foreground and background children, from the agent's config. */
|
|
509
|
+
function agentInvocationArgs(agent: AgentConfig): string[] {
|
|
510
|
+
const args: string[] = ['--mode', 'json', '-p', '--no-session']
|
|
511
|
+
// pi reads a thinking level from the model pattern's :suffix, so Claude's effort
|
|
512
|
+
// has a seam only when the agent pins a concrete model.
|
|
513
|
+
if (agent.model) args.push('--model', agent.effort ? `${agent.model}:${agent.effort}` : agent.model)
|
|
514
|
+
if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
|
|
515
|
+
if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', agent.disallowedTools.join(','))
|
|
516
|
+
return args
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
520
|
+
return {
|
|
521
|
+
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
522
|
+
details: makeDetails('single')([]),
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefined): void {
|
|
527
|
+
if (!tmpPrompt) return
|
|
528
|
+
try {
|
|
529
|
+
fs.unlinkSync(tmpPrompt.filePath)
|
|
530
|
+
} catch {
|
|
531
|
+
/* ignore */
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
fs.rmdirSync(tmpPrompt.dir)
|
|
535
|
+
} catch {
|
|
536
|
+
/* ignore */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
507
540
|
async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails): Promise<ToolResult> {
|
|
508
541
|
const task = params.task
|
|
509
542
|
const agentName = params.agent
|
|
@@ -521,9 +554,10 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
521
554
|
details: makeDetails('single')([]),
|
|
522
555
|
}
|
|
523
556
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
557
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
|
|
558
|
+
return backgroundCapResult(makeDetails)
|
|
559
|
+
}
|
|
560
|
+
const args = agentInvocationArgs(agent)
|
|
527
561
|
let tmpPrompt: { dir: string; filePath: string } | undefined
|
|
528
562
|
if (agent.systemPrompt.trim()) {
|
|
529
563
|
tmpPrompt = await writePromptToTempFile(agent.name, agent.systemPrompt)
|
|
@@ -532,19 +566,8 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
532
566
|
args.push(`Task: ${task}`)
|
|
533
567
|
const invocation = getPiInvocation(args)
|
|
534
568
|
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
fs.unlinkSync(tmpPrompt.filePath)
|
|
538
|
-
} catch {
|
|
539
|
-
/* ignore */
|
|
540
|
-
}
|
|
541
|
-
try {
|
|
542
|
-
fs.rmdirSync(tmpPrompt.dir)
|
|
543
|
-
} catch {
|
|
544
|
-
/* ignore */
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
const output = run.output || '(no output)'
|
|
569
|
+
removeTmpPrompt(tmpPrompt)
|
|
570
|
+
const output = capForContext(run.output ?? '') || '(no output)'
|
|
548
571
|
pi.sendMessage(
|
|
549
572
|
{
|
|
550
573
|
customType: 'subagent-background',
|
|
@@ -554,6 +577,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
554
577
|
{ triggerTurn: true },
|
|
555
578
|
)
|
|
556
579
|
})
|
|
580
|
+
if (id === null) {
|
|
581
|
+
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
582
|
+
removeTmpPrompt(tmpPrompt)
|
|
583
|
+
return backgroundCapResult(makeDetails)
|
|
584
|
+
}
|
|
557
585
|
return {
|
|
558
586
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
559
587
|
details: makeDetails('single')([]),
|
|
@@ -567,7 +595,8 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
567
595
|
|
|
568
596
|
for (let i = 0; i < chain.length; i++) {
|
|
569
597
|
const step = chain[i]
|
|
570
|
-
|
|
598
|
+
// Function replacement: a string here would interpret $-patterns in the output.
|
|
599
|
+
const taskWithContext = step.task.replaceAll('{previous}', () => previousOutput)
|
|
571
600
|
|
|
572
601
|
// Create update callback that includes all previous results
|
|
573
602
|
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
@@ -601,14 +630,14 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
601
630
|
if (isError) {
|
|
602
631
|
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
603
632
|
return {
|
|
604
|
-
content: [{ type: 'text', text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
|
633
|
+
content: [{ type: 'text', text: capForContext(`Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`) }],
|
|
605
634
|
details: makeDetails('chain')(results),
|
|
606
635
|
}
|
|
607
636
|
}
|
|
608
637
|
previousOutput = getFinalOutput(result.messages)
|
|
609
638
|
}
|
|
610
639
|
return {
|
|
611
|
-
content: [{ type: 'text', text: getFinalOutput(results.at(-1)?.messages ?? []) || '(no output)' }],
|
|
640
|
+
content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
|
|
612
641
|
details: makeDetails('chain')(results),
|
|
613
642
|
}
|
|
614
643
|
}
|
|
@@ -678,15 +707,15 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
678
707
|
const successCount = results.filter((r) => r.exitCode === 0).length
|
|
679
708
|
const summaries = results.map((r) => {
|
|
680
709
|
const output = getFinalOutput(r.messages)
|
|
681
|
-
const preview = output.slice(0, 100) + (output.length > 100 ? '...' : '')
|
|
682
710
|
const status = r.exitCode === 0 ? 'completed' : 'failed'
|
|
683
|
-
return `[${r.agent}] ${status}: ${
|
|
711
|
+
return `[${r.agent}] ${status}: ${output || '(no output)'}`
|
|
684
712
|
})
|
|
685
713
|
return {
|
|
686
714
|
content: [
|
|
687
715
|
{
|
|
688
716
|
type: 'text',
|
|
689
|
-
|
|
717
|
+
// Full reports, so a fan-out can be synthesized from; capped at pi's tool-output budget.
|
|
718
|
+
text: capForContext(`Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`),
|
|
690
719
|
},
|
|
691
720
|
],
|
|
692
721
|
details: makeDetails('parallel')(results),
|
|
@@ -709,12 +738,12 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
|
|
|
709
738
|
if (isError) {
|
|
710
739
|
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
711
740
|
return {
|
|
712
|
-
content: [{ type: 'text', text: `Agent ${result.stopReason || 'failed'}: ${errorMsg}` }],
|
|
741
|
+
content: [{ type: 'text', text: capForContext(`Agent ${result.stopReason || 'failed'}: ${errorMsg}`) }],
|
|
713
742
|
details: makeDetails('single')([result]),
|
|
714
743
|
}
|
|
715
744
|
}
|
|
716
745
|
return {
|
|
717
|
-
content: [{ type: 'text', text: getFinalOutput(result.messages) || '(no output)' }],
|
|
746
|
+
content: [{ type: 'text', text: capForContext(getFinalOutput(result.messages)) || '(no output)' }],
|
|
718
747
|
details: makeDetails('single')([result]),
|
|
719
748
|
}
|
|
720
749
|
}
|
|
@@ -1014,13 +1043,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1014
1043
|
'Delegate tasks to specialized subagents with isolated context.',
|
|
1015
1044
|
'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
|
|
1016
1045
|
'Single mode also supports background: true for long tasks; a notification arrives on completion and {status: true} lists runs.',
|
|
1017
|
-
'Default agent scope is "user" (from ~/.pi/agent/agents).',
|
|
1018
|
-
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
|
1046
|
+
'Default agent scope is "user" (from ~/.claude/agents and ~/.pi/agent/agents).',
|
|
1047
|
+
'To enable project-local agents in .claude/agents or .pi/agents, set agentScope: "both" (or "project").',
|
|
1019
1048
|
].join(' '),
|
|
1020
1049
|
parameters: SubagentParams,
|
|
1021
1050
|
|
|
1022
1051
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1023
1052
|
const agentScope: AgentScope = params.agentScope ?? 'user'
|
|
1053
|
+
// Children carry PI_CODE_SUBAGENT; without this check they could spawn
|
|
1054
|
+
// grandchildren without limit.
|
|
1055
|
+
if (process.env.PI_CODE_SUBAGENT) {
|
|
1056
|
+
return {
|
|
1057
|
+
content: [{ type: 'text', text: 'Nested subagent runs are not allowed: this session is already a subagent.' }],
|
|
1058
|
+
details: { mode: 'single', agentScope, projectAgentsDir: null, results: [] },
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1024
1061
|
const discovery = discoverAgents(ctx.cwd, agentScope)
|
|
1025
1062
|
const agents = discovery.agents
|
|
1026
1063
|
|
package/extensions/web.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type { LookupFunction } from 'node:net'
|
|
|
12
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
13
|
import { Type } from 'typebox'
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { capForContext } from './internal/output-guard.js'
|
|
16
|
+
import { httpFetch } from './internal/web-transport.js'
|
|
16
17
|
|
|
17
18
|
const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
|
|
18
19
|
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
|
|
@@ -59,16 +60,18 @@ export function resolveResultUrl(href: string): string {
|
|
|
59
60
|
export function parseSearchResults(html: string, limit: number): SearchResult[] {
|
|
60
61
|
const results: SearchResult[] = []
|
|
61
62
|
const anchorPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
|
|
62
|
-
const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/
|
|
63
|
-
const
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
if (results.length >= limit) break
|
|
63
|
+
const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/
|
|
64
|
+
const anchors = [...html.matchAll(anchorPattern)]
|
|
65
|
+
for (let i = 0; i < anchors.length && results.length < limit; i++) {
|
|
66
|
+
const match = anchors[i]
|
|
67
67
|
const url = resolveResultUrl(match[1])
|
|
68
68
|
const title = stripTags(match[2])
|
|
69
69
|
if (!title || url.includes('duckduckgo.com/y.js')) continue
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// A result's snippet sits between its anchor and the next one; pairing by block
|
|
71
|
+
// keeps attribution right when an ad anchor (skipped above) carries a snippet too.
|
|
72
|
+
const block = html.slice((match.index ?? 0) + match[0].length, anchors[i + 1]?.index ?? html.length)
|
|
73
|
+
const snippet = snippetPattern.exec(block)
|
|
74
|
+
results.push({ title, url, snippet: snippet ? stripTags(snippet[1]) : '' })
|
|
72
75
|
}
|
|
73
76
|
return results
|
|
74
77
|
}
|
|
@@ -173,6 +176,9 @@ async function readCapped(response: Response): Promise<string> {
|
|
|
173
176
|
if (done) break
|
|
174
177
|
if (value) text += decoder.decode(value, { stream: true })
|
|
175
178
|
}
|
|
179
|
+
// Flush: bytes of a character cut off by the end of the stream become U+FFFD
|
|
180
|
+
// instead of vanishing silently.
|
|
181
|
+
text += decoder.decode()
|
|
176
182
|
await reader.cancel().catch(() => {})
|
|
177
183
|
return text.slice(0, MAX_RAW_CHARS)
|
|
178
184
|
}
|
|
@@ -233,7 +239,9 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
233
239
|
}
|
|
234
240
|
const { text, contentType } = await fetchText(params.url)
|
|
235
241
|
const body = contentType.includes('html') ? htmlToText(text) : text.slice(0, MAX_FETCH_CHARS)
|
|
236
|
-
|
|
242
|
+
// The char cap alone admits thousands of short lines; pi's tool-output budget
|
|
243
|
+
// bounds lines too, which the shared guard enforces.
|
|
244
|
+
return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
|
|
237
245
|
},
|
|
238
246
|
})
|
|
239
247
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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-package"
|
|
File without changes
|
|
File without changes
|