pi-code 1.0.3 → 1.0.5
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 +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +377 -53
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- package/package.json +1 -1
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Extracted for testability.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { hasSubstitution, splitSegments } from '../internal/shell-split.js'
|
|
7
|
+
|
|
6
8
|
// Destructive commands blocked in plan mode
|
|
7
9
|
const DESTRUCTIVE_PATTERNS = [
|
|
8
10
|
/\brm\b/i,
|
|
@@ -90,62 +92,6 @@ const SAFE_PATTERNS = [
|
|
|
90
92
|
/^\s*eza\b/,
|
|
91
93
|
]
|
|
92
94
|
|
|
93
|
-
// The shell can hide an arbitrary command inside any of these, so they are refused
|
|
94
|
-
// outright rather than parsed.
|
|
95
|
-
const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
|
|
99
|
-
* newline) so every subcommand is checked on its own, ignoring separators inside quotes:
|
|
100
|
-
* `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
|
|
101
|
-
* fails the caller closed rather than guessing at the intended split.
|
|
102
|
-
*
|
|
103
|
-
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
104
|
-
*/
|
|
105
|
-
/** Length of the separator at `i`, or 0 when there is none. */
|
|
106
|
-
function separatorAt(command: string, i: number): number {
|
|
107
|
-
const pair = command.slice(i, i + 2)
|
|
108
|
-
if (pair === '&&' || pair === '||' || pair === '|&') return 2
|
|
109
|
-
const ch = command[i]
|
|
110
|
-
return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function splitSegments(command: string): string[] {
|
|
114
|
-
const segments: string[] = []
|
|
115
|
-
let current = ''
|
|
116
|
-
let quote: "'" | '"' | undefined
|
|
117
|
-
|
|
118
|
-
for (let i = 0; i < command.length; i++) {
|
|
119
|
-
const ch = command[i]
|
|
120
|
-
if (quote !== undefined) {
|
|
121
|
-
current += ch
|
|
122
|
-
if (ch === quote) quote = undefined
|
|
123
|
-
continue
|
|
124
|
-
}
|
|
125
|
-
if (ch === "'" || ch === '"') {
|
|
126
|
-
quote = ch
|
|
127
|
-
current += ch
|
|
128
|
-
continue
|
|
129
|
-
}
|
|
130
|
-
if (ch === '\\' && i + 1 < command.length) {
|
|
131
|
-
current += ch + command[++i]
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
134
|
-
const separator = separatorAt(command, i)
|
|
135
|
-
if (separator > 0) {
|
|
136
|
-
segments.push(current)
|
|
137
|
-
current = ''
|
|
138
|
-
i += separator - 1
|
|
139
|
-
continue
|
|
140
|
-
}
|
|
141
|
-
current += ch
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (quote !== undefined) return []
|
|
145
|
-
segments.push(current)
|
|
146
|
-
return segments.map((segment) => segment.trim()).filter(Boolean)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
95
|
// find is allowlisted for traversal only; these actions run commands or delete.
|
|
150
96
|
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
151
97
|
|
|
@@ -163,7 +109,7 @@ function isSafeSegment(segment: string): boolean {
|
|
|
163
109
|
* containing a determined one. Only OS-level isolation would be a boundary.
|
|
164
110
|
*/
|
|
165
111
|
export function isSafeCommand(command: string): boolean {
|
|
166
|
-
if (
|
|
112
|
+
if (hasSubstitution(command)) return false
|
|
167
113
|
const segments = splitSegments(command)
|
|
168
114
|
return segments.length > 0 && segments.every(isSafeSegment)
|
|
169
115
|
}
|
package/extensions/question.ts
CHANGED
|
@@ -35,7 +35,7 @@ const OptionSchema = Type.Object({
|
|
|
35
35
|
const SingleQuestion = Type.Object({
|
|
36
36
|
question: Type.String({ description: 'The question to ask the user' }),
|
|
37
37
|
header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it, kept to 12 characters' })),
|
|
38
|
-
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (
|
|
38
|
+
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (2-4)', minItems: 2, maxItems: 4 }),
|
|
39
39
|
multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
|
|
40
40
|
})
|
|
41
41
|
|
|
@@ -44,7 +44,7 @@ const SingleQuestion = Type.Object({
|
|
|
44
44
|
* shapes gave smaller models nothing to follow, and they produced neither. */
|
|
45
45
|
export const QuestionParams = Type.Object({
|
|
46
46
|
question: Type.Optional(Type.String({ description: 'The question to ask. Required, unless asking several via questions.' })),
|
|
47
|
-
options: Type.Optional(Type.Array(OptionSchema, { description: 'The
|
|
47
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: 'The 2-4 choices for this question, each {label, description?}. Required with question.', minItems: 2, maxItems: 4 })),
|
|
48
48
|
header: Type.Optional(Type.String({ description: 'Optional short label shown above the question, kept to 12 characters' })),
|
|
49
49
|
multiSelect: Type.Optional(Type.Boolean({ description: 'Optional: allow selecting several options (space toggles, enter confirms)' })),
|
|
50
50
|
questions: Type.Optional(Type.Array(SingleQuestion, { description: 'Only to ask 2-4 questions in one call: each entry takes the same fields as above. Leave unset for a single question.', minItems: 1, maxItems: 4 })),
|
package/extensions/skills.ts
CHANGED
|
@@ -15,7 +15,9 @@ import * as os from 'node:os'
|
|
|
15
15
|
import * as path from 'node:path'
|
|
16
16
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
17
17
|
|
|
18
|
+
import { installedPlugins } from './internal/plugins.js'
|
|
18
19
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
20
|
+
import { findNearestDir } from './internal/project-root.js'
|
|
19
21
|
|
|
20
22
|
function isDirectory(target: string): boolean {
|
|
21
23
|
try {
|
|
@@ -32,7 +34,15 @@ function isDirectory(target: string): boolean {
|
|
|
32
34
|
* text into the prompt without the user ever agreeing to load its config. */
|
|
33
35
|
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
34
36
|
const candidates = [path.join(home, '.claude', 'skills')]
|
|
35
|
-
|
|
37
|
+
// Enabled plugins contribute their skills directories. pi's loader names a
|
|
38
|
+
// skill by its directory, so a plugin skill registers without Claude's
|
|
39
|
+
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|
|
40
|
+
for (const plugin of installedPlugins(home)) {
|
|
41
|
+
const declared = plugin.manifest.skills
|
|
42
|
+
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
|
|
43
|
+
candidates.push(...dirs.map((dir) => path.resolve(plugin.root, String(dir))))
|
|
44
|
+
}
|
|
45
|
+
if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'skills')) ?? path.join(cwd, '.claude', 'skills'))
|
|
36
46
|
const dirs: string[] = []
|
|
37
47
|
for (const dir of candidates) {
|
|
38
48
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import * as fs from 'node:fs'
|
|
23
23
|
import * as os from 'node:os'
|
|
24
|
+
import * as path from 'node:path'
|
|
24
25
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
25
26
|
|
|
26
27
|
import { hookFiles, runHookCommand } from './hooks.js'
|
|
@@ -31,6 +32,15 @@ import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
|
31
32
|
const COMMAND_TIMEOUT_MS = 5_000
|
|
32
33
|
const DEBOUNCE_MS = 300
|
|
33
34
|
|
|
35
|
+
/** Claude sends its CLI version; pi-code's own version is the honest analogue. */
|
|
36
|
+
const PACKAGE_VERSION = (() => {
|
|
37
|
+
try {
|
|
38
|
+
return String(JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8')).version ?? '')
|
|
39
|
+
} catch {
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
})()
|
|
43
|
+
|
|
34
44
|
interface UsageEntry {
|
|
35
45
|
type: string
|
|
36
46
|
message?: { usage?: { cost?: { total?: number } } }
|
|
@@ -84,6 +94,17 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
84
94
|
let commandLine: string | undefined
|
|
85
95
|
let permissionMode = 'default'
|
|
86
96
|
let projectApproved = false
|
|
97
|
+
let sessionStartMs = Date.now()
|
|
98
|
+
// Lines changed, counted from successful edit/write inputs: newText and content
|
|
99
|
+
// lines add, oldText lines remove. An approximation of Claude's counters, which
|
|
100
|
+
// is honest for the tools pi has; bash-side changes are invisible to both.
|
|
101
|
+
let linesAdded = 0
|
|
102
|
+
let linesRemoved = 0
|
|
103
|
+
// API timing and the last message's token usage, from provider/message events:
|
|
104
|
+
// the fields ctx.getContextUsage() does not expose (output/cache tokens, API time).
|
|
105
|
+
let apiDurationMs = 0
|
|
106
|
+
let requestStartMs: number | undefined
|
|
107
|
+
let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
|
|
87
108
|
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
|
88
109
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
89
110
|
let running = false
|
|
@@ -104,21 +125,57 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
104
125
|
/** The stdin payload per Claude's documented statusline contract. */
|
|
105
126
|
function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
|
|
106
127
|
const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
|
|
128
|
+
const model = ctx.model as { id?: string; name?: string } | undefined
|
|
107
129
|
// Same gate as the config read above: an unapproved project's style is not applied,
|
|
108
130
|
// so reporting it here would describe a style the session is not using.
|
|
109
131
|
const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
|
|
110
132
|
const payload: Record<string, unknown> = {
|
|
133
|
+
hook_event_name: 'Status',
|
|
111
134
|
session_id: ctx.sessionManager.getSessionId(),
|
|
112
135
|
cwd: ctx.cwd,
|
|
136
|
+
version: PACKAGE_VERSION,
|
|
113
137
|
workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
// Both fields, per Claude's documented contract: published statusline scripts
|
|
139
|
+
// read .model.display_name and render the literal "null" when it is missing.
|
|
140
|
+
model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
|
|
141
|
+
cost: {
|
|
142
|
+
total_cost_usd: sessionCost(ctx),
|
|
143
|
+
total_duration_ms: Date.now() - sessionStartMs,
|
|
144
|
+
total_api_duration_ms: apiDurationMs,
|
|
145
|
+
total_lines_added: linesAdded,
|
|
146
|
+
total_lines_removed: linesRemoved,
|
|
147
|
+
},
|
|
148
|
+
context_window: {
|
|
149
|
+
context_window_size: usage.contextWindow,
|
|
150
|
+
used_percentage: usage.percent,
|
|
151
|
+
remaining_percentage: usage.percent === null ? null : 100 - usage.percent,
|
|
152
|
+
total_input_tokens: usage.tokens,
|
|
153
|
+
// The per-component breakdown from the last message's usage, which
|
|
154
|
+
// ctx.getContextUsage() (input-side estimate only) cannot provide.
|
|
155
|
+
...(lastUsage
|
|
156
|
+
? {
|
|
157
|
+
total_output_tokens: lastUsage.output,
|
|
158
|
+
current_usage: {
|
|
159
|
+
input_tokens: lastUsage.input,
|
|
160
|
+
output_tokens: lastUsage.output,
|
|
161
|
+
cache_read_input_tokens: lastUsage.cacheRead,
|
|
162
|
+
cache_creation_input_tokens: lastUsage.cacheWrite,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
166
|
+
},
|
|
167
|
+
// The true combined total when a message usage is known, else the input-side estimate.
|
|
168
|
+
exceeds_200k_tokens: (lastUsage?.totalTokens ?? usage.tokens ?? 0) > 200_000,
|
|
117
169
|
permission_mode: permissionMode,
|
|
118
170
|
}
|
|
119
171
|
const transcript = ctx.sessionManager.getSessionFile()
|
|
120
172
|
if (transcript) payload.transcript_path = transcript
|
|
121
|
-
|
|
173
|
+
const sessionName = ctx.sessionManager.getSessionName?.()
|
|
174
|
+
if (sessionName) payload.session_name = sessionName
|
|
175
|
+
if (ctx.thinkingLevel) {
|
|
176
|
+
payload.effort = { level: ctx.thinkingLevel }
|
|
177
|
+
payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
|
|
178
|
+
}
|
|
122
179
|
if (styleName) payload.output_style = { name: styleName }
|
|
123
180
|
return payload
|
|
124
181
|
}
|
|
@@ -167,11 +224,47 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
167
224
|
scheduleRefresh()
|
|
168
225
|
})
|
|
169
226
|
|
|
227
|
+
// Counted here rather than in buildPayload so the numbers accumulate across the
|
|
228
|
+
// session the way Claude's counters do.
|
|
229
|
+
pi.on('tool_result', async (event) => {
|
|
230
|
+
if (event.isError) return
|
|
231
|
+
const input = event.input as Record<string, unknown>
|
|
232
|
+
const lines = (text: unknown): number => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
|
|
233
|
+
if (event.toolName === 'write') linesAdded += lines(input.content)
|
|
234
|
+
if (event.toolName === 'edit' && Array.isArray(input.edits)) {
|
|
235
|
+
for (const edit of input.edits as Array<{ oldText?: unknown; newText?: unknown }>) {
|
|
236
|
+
linesAdded += lines(edit.newText)
|
|
237
|
+
linesRemoved += lines(edit.oldText)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// API round-trip timing: the window between the request and its response, summed
|
|
243
|
+
// across the session. ctx exposes no API-duration getter, so it is measured here.
|
|
244
|
+
pi.on('before_provider_request', async () => {
|
|
245
|
+
requestStartMs = Date.now()
|
|
246
|
+
})
|
|
247
|
+
pi.on('after_provider_response', async () => {
|
|
248
|
+
if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
|
|
249
|
+
requestStartMs = undefined
|
|
250
|
+
})
|
|
251
|
+
// The last message's token usage, for the breakdown getContextUsage() omits.
|
|
252
|
+
pi.on('message_end', async (event) => {
|
|
253
|
+
const usage = (event as { message?: { usage?: typeof lastUsage } }).message?.usage
|
|
254
|
+
if (usage) lastUsage = usage
|
|
255
|
+
})
|
|
256
|
+
|
|
170
257
|
pi.on('session_start', async (_event, ctx) => {
|
|
171
258
|
// One instance serves every session, so a fresh session must not inherit state.
|
|
172
259
|
turnCount = 0
|
|
173
260
|
commandLine = undefined
|
|
174
261
|
sessionCtx = ctx
|
|
262
|
+
sessionStartMs = Date.now()
|
|
263
|
+
linesAdded = 0
|
|
264
|
+
linesRemoved = 0
|
|
265
|
+
apiDurationMs = 0
|
|
266
|
+
requestStartMs = undefined
|
|
267
|
+
lastUsage = undefined
|
|
175
268
|
clearInterval(refreshTimer)
|
|
176
269
|
// Reading config must never open a trust dialog: several extensions resolve
|
|
177
270
|
// approval at session start, and a second prompt stacks over the first and eats
|
|
@@ -10,22 +10,31 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
|
|
|
10
10
|
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
11
11
|
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
12
|
// is not merely ignored, it narrows the child's registry.
|
|
13
|
-
import {
|
|
13
|
+
import { parseToolGrants } from '../internal/command-file.js'
|
|
14
|
+
import { installedPlugins } from '../internal/plugins.js'
|
|
15
|
+
import { findNearestDir } from '../internal/project-root.js'
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
17
19
|
* list. Anything else returns null: a restriction that failed to parse must not run
|
|
18
20
|
* the agent unrestricted.
|
|
21
|
+
*
|
|
22
|
+
* An argument-scoped grant (`Bash(git log:*)`) cannot be expressed in the child's
|
|
23
|
+
* --tools allowlist, and the parent cannot reach into the child process to enforce
|
|
24
|
+
* it at call time the way commands.ts does, so on the granting side it is rejected
|
|
25
|
+
* like any other unexpressable restriction. A scoped *disallow* only denies more
|
|
26
|
+
* than the file asked for, which is the safe direction, so it stands.
|
|
19
27
|
*/
|
|
20
|
-
function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
28
|
+
function parseToolsField(raw: unknown, granting: boolean): string[] | undefined | null {
|
|
21
29
|
if (raw === undefined) return undefined
|
|
22
30
|
// Shares the command parser's splitting, so a comma inside an argument scope stays
|
|
23
31
|
// inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
|
|
24
32
|
if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
|
|
25
33
|
if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
|
|
34
|
+
const grants = parseToolGrants(raw)
|
|
35
|
+
if (!grants) return null
|
|
36
|
+
if (granting && grants.scopedEntries.length > 0) return null
|
|
37
|
+
return grants.tools.length > 0 ? grants.tools : undefined
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -35,7 +44,7 @@ function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
|
35
44
|
* (Claude's `inherit`) is the degradation that works everywhere; users who want a
|
|
36
45
|
* tier pinned should name a concrete model id.
|
|
37
46
|
*/
|
|
38
|
-
const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
|
|
47
|
+
const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable', 'inherit'])
|
|
39
48
|
|
|
40
49
|
function parseModelField(raw: unknown): string | undefined {
|
|
41
50
|
if (typeof raw !== 'string') return undefined
|
|
@@ -70,6 +79,20 @@ function parseEffortField(raw: unknown): string | undefined {
|
|
|
70
79
|
return THINKING_LEVELS.has(effort) ? effort : undefined
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
/** Claude's agent `memory:` scopes: a persistent per-agent store for cross-session
|
|
83
|
+
* learning, separate from the parent conversation's auto memory. */
|
|
84
|
+
export type AgentMemoryScope = 'user' | 'project' | 'local'
|
|
85
|
+
|
|
86
|
+
const MEMORY_SCOPES: ReadonlySet<string> = new Set(['user', 'project', 'local'])
|
|
87
|
+
|
|
88
|
+
/** Anything other than the three scopes is ignored, so the agent still runs, just
|
|
89
|
+
* without memory; a typo in an optional enhancement should not drop the agent. */
|
|
90
|
+
function parseMemoryField(raw: unknown): AgentMemoryScope | undefined {
|
|
91
|
+
if (typeof raw !== 'string') return undefined
|
|
92
|
+
const scope = raw.trim().toLowerCase()
|
|
93
|
+
return MEMORY_SCOPES.has(scope) ? (scope as AgentMemoryScope) : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
/** Claude's `skills` frontmatter: a comma string or YAML list of skill names. */
|
|
74
97
|
function parseSkillsField(raw: unknown): string[] | undefined {
|
|
75
98
|
let names: string[] = []
|
|
@@ -145,9 +168,15 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
145
168
|
const name = typeof frontmatter.name === 'string' ? frontmatter.name : ''
|
|
146
169
|
const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
|
|
147
170
|
if (!name || !description) return null
|
|
148
|
-
const tools = parseToolsField(frontmatter.tools)
|
|
149
|
-
if (tools === null)
|
|
150
|
-
|
|
171
|
+
const tools = parseToolsField(frontmatter.tools, true)
|
|
172
|
+
if (tools === null) {
|
|
173
|
+
// A silent drop reads as "agent does not exist"; say why, since an
|
|
174
|
+
// argument-scoped grant is a shape Claude's own docs recommend but pi cannot
|
|
175
|
+
// enforce on a child process.
|
|
176
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: its tools: grant could not be applied (an argument scope like Bash(git log:*) cannot be enforced on a subagent; grant the whole tool or drop it)`)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
const disallowedTools = parseToolsField(frontmatter.disallowedTools, false)
|
|
151
180
|
if (disallowedTools === null) return null
|
|
152
181
|
return {
|
|
153
182
|
name,
|
|
@@ -158,12 +187,20 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
158
187
|
effort: parseEffortField(frontmatter.effort),
|
|
159
188
|
modelAlias: parseModelAlias(frontmatter.model),
|
|
160
189
|
skills: parseSkillsField(frontmatter.skills),
|
|
190
|
+
memory: parseMemoryField(frontmatter.memory),
|
|
191
|
+
maxTurns: parseMaxTurns(frontmatter.maxTurns),
|
|
161
192
|
systemPrompt: body,
|
|
162
193
|
source,
|
|
163
194
|
filePath,
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
|
|
198
|
+
/** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
|
|
199
|
+
* Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
|
|
200
|
+
function parseMaxTurns(raw: unknown): number | undefined {
|
|
201
|
+
return typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
167
204
|
export type AgentScope = 'user' | 'project' | 'both'
|
|
168
205
|
|
|
169
206
|
export interface AgentConfig {
|
|
@@ -177,6 +214,10 @@ export interface AgentConfig {
|
|
|
177
214
|
modelAlias?: string
|
|
178
215
|
/** Skill names to inline into the child's prompt, per Claude's `skills` field. */
|
|
179
216
|
skills?: string[]
|
|
217
|
+
/** Persistent per-agent memory scope, per Claude's `memory:` field. */
|
|
218
|
+
memory?: AgentMemoryScope
|
|
219
|
+
/** Cap on the child's agentic turns, enforced by killing at the turn boundary. */
|
|
220
|
+
maxTurns?: number
|
|
180
221
|
systemPrompt: string
|
|
181
222
|
source: AgentSource
|
|
182
223
|
filePath: string
|
|
@@ -187,25 +228,27 @@ export interface AgentDiscoveryResult {
|
|
|
187
228
|
projectAgentsDir: string | null
|
|
188
229
|
}
|
|
189
230
|
|
|
231
|
+
/** Claude scans .claude/agents recursively so agents can be organized into
|
|
232
|
+
* subfolders (agents/review/, agents/research/); the walk mirrors that. */
|
|
190
233
|
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
191
234
|
const agents: AgentConfig[] = []
|
|
192
235
|
|
|
193
|
-
if (!fs.existsSync(dir)) {
|
|
194
|
-
return agents
|
|
195
|
-
}
|
|
196
|
-
|
|
197
236
|
let entries: fs.Dirent[]
|
|
198
237
|
try {
|
|
199
238
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
200
239
|
} catch {
|
|
201
|
-
return agents
|
|
240
|
+
return agents // a missing or unreadable directory contributes nothing
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
for (const entry of entries) {
|
|
244
|
+
const filePath = path.join(dir, entry.name)
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
agents.push(...loadAgentsFromDir(filePath, source))
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
205
249
|
if (!entry.name.endsWith('.md')) continue
|
|
206
250
|
if (!entry.isFile() && !entry.isSymbolicLink()) continue
|
|
207
251
|
|
|
208
|
-
const filePath = path.join(dir, entry.name)
|
|
209
252
|
let content: string
|
|
210
253
|
try {
|
|
211
254
|
content = fs.readFileSync(filePath, 'utf-8')
|
|
@@ -220,49 +263,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
220
263
|
return agents
|
|
221
264
|
}
|
|
222
265
|
|
|
223
|
-
function isDirectory(p: string): boolean {
|
|
224
|
-
try {
|
|
225
|
-
return fs.statSync(p).isDirectory()
|
|
226
|
-
} catch {
|
|
227
|
-
return false
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Project root at or above `from`. `.git` is a file in worktrees and submodules. */
|
|
232
|
-
const ROOT_MARKERS = ['.git', 'package.json']
|
|
233
|
-
|
|
234
|
-
function repoRoot(from: string): string | undefined {
|
|
235
|
-
let currentDir = from
|
|
236
|
-
while (true) {
|
|
237
|
-
if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
|
|
238
|
-
const parentDir = path.dirname(currentDir)
|
|
239
|
-
if (parentDir === currentDir) return undefined
|
|
240
|
-
currentDir = parentDir
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Nearest `relative` directory at or above `cwd`, stopping at the repository root.
|
|
246
|
-
*
|
|
247
|
-
* Without the boundary the search runs to the filesystem root, so an agent planted in a
|
|
248
|
-
* world-writable ancestor such as /tmp is offered as a project agent for every session
|
|
249
|
-
* beneath it. With no project marker (.git, package.json) the extent is unknown, so only
|
|
250
|
-
* `cwd` is considered.
|
|
251
|
-
*/
|
|
252
|
-
function findNearestDir(cwd: string, relative: string): string | null {
|
|
253
|
-
const boundary = repoRoot(cwd) ?? cwd
|
|
254
|
-
let currentDir = cwd
|
|
255
|
-
while (true) {
|
|
256
|
-
const candidate = path.join(currentDir, relative)
|
|
257
|
-
if (isDirectory(candidate)) return candidate
|
|
258
|
-
|
|
259
|
-
if (currentDir === boundary) return null
|
|
260
|
-
const parentDir = path.dirname(currentDir)
|
|
261
|
-
if (parentDir === currentDir) return null
|
|
262
|
-
currentDir = parentDir
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
266
|
function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
|
|
267
267
|
const agentMap = new Map<string, AgentConfig>()
|
|
268
268
|
const register = (agents: AgentConfig[]): void => {
|
|
@@ -274,19 +274,30 @@ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[],
|
|
|
274
274
|
return agentMap
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
export type AgentSource = 'user' | 'project' | 'builtin'
|
|
277
|
+
export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
|
|
278
278
|
|
|
279
279
|
/** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
|
|
280
280
|
export const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
|
|
281
281
|
|
|
282
|
+
/** Agent directories of every enabled plugin: `agents/` unless the manifest
|
|
283
|
+
* points elsewhere. Plugins are user-installed, so user scope only decides. */
|
|
284
|
+
function pluginAgentDirs(home: string): string[] {
|
|
285
|
+
return installedPlugins(home).flatMap((plugin) => {
|
|
286
|
+
const declared = plugin.manifest.agents
|
|
287
|
+
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
|
|
288
|
+
return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
282
292
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
283
293
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
284
294
|
const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
|
|
285
295
|
const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
|
|
286
296
|
const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
|
|
287
297
|
|
|
288
|
-
//
|
|
289
|
-
|
|
298
|
+
// Plugins load after builtins and before the user's own dirs, so a user agent
|
|
299
|
+
// wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
|
|
300
|
+
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
|
|
290
301
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
291
302
|
const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
292
303
|
|