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.
Files changed (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. 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 (SUBSTITUTION.test(command)) return false
112
+ if (hasSubstitution(command)) return false
167
113
  const segments = splitSegments(command)
168
114
  return segments.length > 0 && segments.every(isSafeSegment)
169
115
  }
@@ -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 (1-4)', minItems: 1, maxItems: 4 }),
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 1-4 choices for this question, each {label, description?}. Required with question.', minItems: 1, maxItems: 4 })),
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 })),
@@ -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
- if (trusted) candidates.push(path.join(cwd, '.claude', 'skills'))
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
- model: { id: (ctx.model as { id?: string } | undefined)?.id ?? '' },
115
- cost: { total_cost_usd: sessionCost(ctx) },
116
- context_window: { context_window_size: usage.contextWindow, used_percentage: usage.percent, total_input_tokens: usage.tokens },
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
- if (ctx.thinkingLevel) payload.effort = { level: ctx.thinkingLevel }
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 { parseToolList } from '../internal/command-file.js'
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 tools = parseToolList(raw)
27
- if (!tools) return null
28
- return tools.length > 0 ? tools : undefined
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) return null
150
- const disallowedTools = parseToolsField(frontmatter.disallowedTools)
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
- // ~/.claude/agents loads first so ~/.pi/agent/agents wins on name conflicts
289
- const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
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