pi-code 1.0.50 → 1.0.51

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.
@@ -50,7 +50,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
50
50
  import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
51
51
  import { capForContext } from './internal/output-guard.js'
52
52
  import { matchesPathRules } from './internal/path-rules.js'
53
- import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
53
+ import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
54
54
  import { isProjectApproved } from './internal/project-approval.js'
55
55
  import { ancestorDirs, repoRoot } from './internal/project-root.js'
56
56
  import { claudeSettingsChain } from './internal/settings-chain.js'
@@ -146,7 +146,7 @@ function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
146
146
  const found: DiscoveredCommand[] = []
147
147
  for (const plugin of plugins) {
148
148
  const declared = plugin.manifest.commands
149
- const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) => path.resolve(plugin.root, String(entry)))
149
+ const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) => pluginComponentPath(plugin, String(entry))).filter((dir): dir is string => dir !== undefined)
150
150
  for (const dir of dirs) {
151
151
  for (const command of discoverCommandFiles(dir)) {
152
152
  found.push({ name: `${plugin.name}:${command.name}`, filePath: command.filePath, plugin: { root: plugin.root, dataDir: plugin.dataDir, ...(plugin.userConfig ? { userConfig: plugin.userConfig } : {}) } })
@@ -7,7 +7,7 @@
7
7
  import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
9
  import { readManagedSettings } from '../internal/managed-settings.js'
10
- import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
10
+ import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
11
11
  import { claudeSettingsChain } from '../internal/settings-chain.js'
12
12
 
13
13
  export interface HookCommand {
@@ -227,6 +227,44 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
227
227
  * every hook the plugin declared silently vanished. */
228
228
  const jsonEscape = (value: string): string => JSON.stringify(value).slice(1, -1)
229
229
 
230
+ /** A reference Claude refuses to substitute into anything that reaches a shell. */
231
+ const USER_CONFIG_REF = /\$\{user_config\./
232
+
233
+ /**
234
+ * Drop shell-form hook commands that reference `${user_config.*}`. Claude: "Fields that
235
+ * run in a shell reject `${user_config.*}`: substituting a configured value into a shell
236
+ * command would let the shell run whatever that value contains, so the component fails
237
+ * with an error instead." Exec form (`args`) and every other field still substitute, and
238
+ * the documented alternative is reading CLAUDE_PLUGIN_OPTION_<KEY> from the hook's
239
+ * environment. Sibling hooks in the same file are unaffected: one rejected component
240
+ * costs itself, not the plugin's other hooks.
241
+ */
242
+ function withoutUserConfigShellCommands(raw: string, source: string): string {
243
+ let parsed: { hooks?: Record<string, unknown> }
244
+ try {
245
+ parsed = JSON.parse(raw)
246
+ } catch {
247
+ return raw // mergeHooksJson reports the parse failure
248
+ }
249
+ let dropped = false
250
+ for (const matchers of Object.values(parsed?.hooks ?? {})) {
251
+ if (!Array.isArray(matchers)) continue
252
+ for (const entry of matchers) {
253
+ const record = entry as { hooks?: unknown }
254
+ if (!Array.isArray(record.hooks)) continue
255
+ const kept = record.hooks.filter((hook) => {
256
+ const candidate = hook as { command?: unknown; args?: unknown }
257
+ if (Array.isArray(candidate.args) || typeof candidate.command !== 'string' || !USER_CONFIG_REF.test(candidate.command)) return true
258
+ console.warn(`pi-code-hooks: ignoring a hook in ${source}: a shell-form command cannot reference \${user_config.*}; use exec form with "args", or read CLAUDE_PLUGIN_OPTION_<KEY> from the environment`)
259
+ dropped = true
260
+ return false
261
+ })
262
+ record.hooks = kept
263
+ }
264
+ }
265
+ return dropped ? JSON.stringify(parsed) : raw
266
+ }
267
+
230
268
  export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
231
269
  for (const plugin of plugins) {
232
270
  const declared = plugin.manifest.hooks
@@ -234,12 +272,14 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
234
272
  // numeric event keys), so it falls through to the default path rather than
235
273
  // silently registering nothing.
236
274
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
237
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin, jsonEscape), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
275
+ const inlineSource = `${plugin.name} (plugin.json)`
276
+ mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`)
238
277
  continue
239
278
  }
240
- const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
279
+ const file = pluginComponentPath(plugin, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
280
+ if (file === undefined) continue
241
281
  try {
242
- mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
282
+ mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
243
283
  } catch {
244
284
  // a plugin without hooks contributes nothing
245
285
  }
@@ -14,10 +14,20 @@ import { hasSubstitution, splitSegments } from './shell-split.js'
14
14
 
15
15
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
16
16
 
17
+ /**
18
+ * One rule against one command segment, per Claude's permission table:
19
+ * - `*` stands in for whatever text is in its place.
20
+ * - a trailing `*` with a space before it also matches the bare command, but only when
21
+ * it is the rule's only wildcard (`* --help *` does not match `npm --help`).
22
+ * - that space is part of the rule, so `ls *` does not match `lsof` while `ls*` does.
23
+ * - `:*` is an equivalent spelling of a trailing ` *`, recognized only at the end; a
24
+ * colon anywhere else is a literal character.
25
+ */
17
26
  function matchesRule(segment: string, rule: string): boolean {
18
- if (rule.endsWith(':*')) return segment.startsWith(rule.slice(0, -2))
19
- if (rule.includes('*')) return new RegExp(`^${rule.split('*').map(escapeRegExp).join('[^]*')}$`).test(segment)
20
- return segment === rule
27
+ const normalized = rule.endsWith(':*') ? `${rule.slice(0, -2)} *` : rule
28
+ if (normalized.endsWith(' *') && normalized.indexOf('*') === normalized.length - 1 && segment === normalized.slice(0, -2)) return true
29
+ if (normalized.includes('*')) return new RegExp(`^${normalized.split('*').map(escapeRegExp).join('[^]*')}$`).test(segment)
30
+ return segment === normalized
21
31
  }
22
32
 
23
33
  export function matchesBashRules(command: string, rules: string[]): boolean {
@@ -39,10 +39,26 @@ export interface OAuthServerConfig {
39
39
  authServerMetadataUrl?: string
40
40
  }
41
41
 
42
+ /** The origin a sign-in belongs to, so the same server name at a different endpoint gets
43
+ * its own store. An unparseable url falls back to its raw text rather than to nothing. */
44
+ function endpointKey(endpoint: string | undefined): string {
45
+ if (!endpoint) return ''
46
+ try {
47
+ return new URL(endpoint).origin
48
+ } catch {
49
+ return endpoint
50
+ }
51
+ }
52
+
42
53
  /** A server name is config-controlled text; the digest keeps hostile names inside
43
- * the store directory and distinct names from colliding after sanitization. */
44
- function storeFileFor(serverName: string): string {
45
- const digest = crypto.createHash('sha256').update(serverName).digest('hex').slice(0, 8)
54
+ * the store directory and distinct names from colliding after sanitization. The endpoint
55
+ * rides the digest so a second project reusing a name cannot read the first one's tokens. */
56
+ function storeFileFor(serverName: string, endpoint?: string): string {
57
+ const digest = crypto
58
+ .createHash('sha256')
59
+ .update(`${serverName}\n${endpointKey(endpoint)}`)
60
+ .digest('hex')
61
+ .slice(0, 8)
46
62
  // Collapse disallowed runs to a single hyphen, then strip leading and trailing
47
63
  // hyphens by index. The old /^-+|-+$/g trim rescanned on every hyphen of a long run
48
64
  // (its trailing-anchored branch backtracks per start position), which is quadratic.
@@ -66,8 +82,8 @@ export class FileOAuthProvider implements OAuthClientProvider {
66
82
  // page cannot inject an authorization code into this login (RFC 8252 8.9).
67
83
  private readonly loginState = crypto.randomBytes(16).toString('hex')
68
84
 
69
- constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void, oauth?: OAuthServerConfig) {
70
- this.storePath = storeFileFor(serverName)
85
+ constructor(serverName: string, onRedirect: (authorizationUrl: URL) => void, oauth?: OAuthServerConfig, endpoint?: string) {
86
+ this.storePath = storeFileFor(serverName, endpoint)
71
87
  this.onRedirect = onRedirect
72
88
  this.oauth = oauth
73
89
  try {
@@ -243,6 +243,20 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
243
243
  * substituting into text that is still raw JSON must pass an escapeValue that
244
244
  * JSON-escapes: a Windows root (C:\Users\...) inserted verbatim injects invalid
245
245
  * escape sequences and the subsequent parse throws. */
246
+ /** A plugin component path, resolved inside the plugin root. Claude "rejects a component
247
+ * path that resolves outside the plugin root, such as `../shared-utils`", so an escaping
248
+ * entry yields undefined and its caller skips that component. The check is lexical, which
249
+ * is the rule as documented: a plugin's own root may itself be a symlink (link mode). */
250
+ export function pluginComponentPath(plugin: Pick<InstalledPlugin, 'name' | 'root'>, declared: string): string | undefined {
251
+ const resolved = path.resolve(plugin.root, declared)
252
+ const inside = path.relative(plugin.root, resolved)
253
+ if (inside !== '' && (inside.startsWith(`..${path.sep}`) || inside === '..' || path.isAbsolute(inside))) {
254
+ console.warn(`pi-code-plugins: plugin ${plugin.name} declares the component path "${declared}", which resolves outside its root; ignoring it`)
255
+ return undefined
256
+ }
257
+ return resolved
258
+ }
259
+
246
260
  export function substitutePluginVars(value: string, plugin: InstalledPlugin, escapeValue: (substituted: string) => string = (substituted) => substituted): string {
247
261
  return value
248
262
  .replaceAll('${CLAUDE_PLUGIN_ROOT}', escapeValue(plugin.root))
@@ -8,7 +8,7 @@ import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
9
  import { claudeConfigDir } from '../internal/config-dir.js'
10
10
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
11
- import type { InstalledPlugin } from '../internal/plugins.js'
11
+ import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
12
12
  import { findNearestFile } from '../internal/project-root.js'
13
13
 
14
14
  export interface StdioServerConfig {
@@ -156,7 +156,9 @@ function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown
156
156
  const servers: Record<string, unknown> = {}
157
157
  for (const entry of paths) {
158
158
  try {
159
- const parsed = JSON.parse(fs.readFileSync(path.resolve(plugin.root, entry), 'utf-8'))
159
+ const file = pluginComponentPath(plugin, entry)
160
+ if (file === undefined) continue
161
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
160
162
  Object.assign(servers, parsed.mcpServers ?? {})
161
163
  } catch {
162
164
  // Malformed or missing JSON contributes no entries.
@@ -494,6 +494,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
494
494
  * factory: pi runs the factory for invocations that never start a session. Names still
495
495
  * connected are filtered out, so a later session start only retries servers that failed
496
496
  * or whose transport dropped, without duplicate-name warnings. */
497
+ /** A project server's headersHelper, dropped while the project is unapproved. */
498
+ function withoutUntrustedHelper(name: string, config: ServerConfig): ServerConfig {
499
+ if (!('headersHelper' in config) || config.headersHelper === undefined) return config
500
+ console.warn(`pi-code-mcp: headersHelper not run for server ${name}: the project is not trusted yet; connecting with its static headers alone`)
501
+ const { headersHelper: _dropped, ...rest } = config
502
+ return rest as ServerConfig
503
+ }
504
+
497
505
  async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
498
506
  // Plugin servers merge under the user scope (plugins are user-installed);
499
507
  // the user's own entry wins a name clash with a plugin's. A server toggled off
@@ -510,7 +518,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
510
518
  // a deliberate narrowing of Claude's rule to keep the safe default.
511
519
  // The stored project decision, read without prompting: consent recorded inside
512
520
  // the project only counts once the project itself has been approved.
513
- const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
521
+ const projectApproved = isProjectApprovedSilently(ctx)
522
+ const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), projectApproved)
514
523
  // Tag the scope on each project server: a repository-supplied headersHelper runs
515
524
  // with credential variables stripped, unlike a user-scope one.
516
525
  const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
@@ -519,7 +528,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
519
528
  // stays with the local (user-side) definition, so the project's entry is dropped
520
529
  // here rather than allowed to shadow it.
521
530
  const localNames = localScopeServerNames(os.homedir(), ctx.cwd)
522
- const consented = Object.fromEntries(Object.entries(consentedRaw).filter(([name]) => !localNames.has(name)))
531
+ // Claude: until the folder is trusted, a project server connects with its static
532
+ // headers alone. Consenting to the server is not consenting to run the command it
533
+ // ships, so the helper is dropped (and named once) while the project is unapproved.
534
+ const consented = Object.fromEntries(
535
+ Object.entries(consentedRaw)
536
+ .filter(([name]) => !localNames.has(name))
537
+ .map(([name, config]) => [name, projectApproved ? config : withoutUntrustedHelper(name, config)]),
538
+ )
523
539
  const projectWinners = new Set(Object.keys(consented))
524
540
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
525
541
  // The consented project servers carry no ordering dependency on the user scope:
@@ -55,6 +55,7 @@ export async function runInteractiveOAuth(name: string, config: { url: string; o
55
55
  authUi.notify(`Authorize "${name}" in the browser. If it did not open: ${authorizationUrl}`, 'info')
56
56
  },
57
57
  config.oauth,
58
+ config.url,
58
59
  )
59
60
  const { server, port } = await startCallbackServer(provider.savedRedirectPort())
60
61
  provider.bindRedirectPort(port)
@@ -260,8 +260,13 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
260
260
  const token = resolveBearerToken(config)
261
261
  if (token) headers.Authorization = `Bearer ${token}`
262
262
  // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
263
- // JSON stdout merges over the static headers.
264
- if (config.headersHelper) Object.assign(headers, await runHeadersHelper(fill(config.headersHelper), helperEnv(name, config)))
263
+ // JSON stdout merges over the static headers. The command text is NOT interpolated:
264
+ // Claude expands ${VAR} in command, args, env, url and headers, and expanding it here
265
+ // would read the parent environment, which is the credential set a repository- or
266
+ // plugin-supplied helper must not see. The helper's own shell expands it against the
267
+ // stripped environment instead. Plugin path variables are already substituted in
268
+ // mcp/config.ts, which also refuses ${user_config.*} in a helper.
269
+ if (config.headersHelper) Object.assign(headers, await runHeadersHelper(config.headersHelper, helperEnv(name, config)))
265
270
  warnMissing()
266
271
  // Claude: a configured Authorization header, whether static, a bearer token, or
267
272
  // helper output, is the server's authentication; there is no OAuth fallback for it.
@@ -367,7 +372,7 @@ async function connectHttpFamily(name: string, config: { url: string; oauth?: OA
367
372
  // dynamic registration, keeping it bound to the real callback port. A
368
373
  // pre-configured client (oauth.clientId) rides the silent provider too, so its
369
374
  // stored tokens refresh with the configured credentials.
370
- const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth)
375
+ const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth, config.url)
371
376
  try {
372
377
  const client = newClient()
373
378
  await connectWithTimeout(client, makeTransport(silent?.hasTokens() ? silent : undefined), label)
@@ -26,7 +26,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
26
 
27
27
  import { claudeConfigDir } from './internal/config-dir.js'
28
28
  import { readManagedSettings } from './internal/managed-settings.js'
29
- import { installedPlugins } from './internal/plugins.js'
29
+ import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
30
30
  import { isProjectApproved } from './internal/project-approval.js'
31
31
  import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
32
32
  import { claudeSettingsChain } from './internal/settings-chain.js'
@@ -118,7 +118,7 @@ export function pluginStyleDirs(home: string): string[] {
118
118
  return installedPlugins(home).flatMap((plugin) => {
119
119
  const declared = plugin.manifest.outputStyles
120
120
  const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'output-styles']
121
- return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
121
+ return dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined)
122
122
  })
123
123
  }
124
124
 
@@ -29,7 +29,7 @@ import { runAgent } from './internal/agent-run.js'
29
29
  import { parseCommandFile } from './internal/command-file.js'
30
30
  import { claudeConfigDir } from './internal/config-dir.js'
31
31
  import { managedSettingsFile } from './internal/managed-settings.js'
32
- import { installedPlugins } from './internal/plugins.js'
32
+ import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
33
33
  import { isProjectApprovedSilently } from './internal/project-approval.js'
34
34
  import { ancestorDirs } from './internal/project-root.js'
35
35
  import { claudeSettingsChain } from './internal/settings-chain.js'
@@ -58,7 +58,7 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
58
58
  for (const plugin of installedPlugins(home)) {
59
59
  const declared = plugin.manifest.skills
60
60
  const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
61
- candidates.push(...dirs.map((dir) => path.resolve(plugin.root, String(dir))))
61
+ candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
62
62
  }
63
63
  // Claude loads skills from every .claude/skills between cwd and the repository
64
64
  // root; the list goes nearest-first so findClaudeSkill's first match is the
@@ -12,7 +12,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
12
12
  // is not merely ignored, it narrows the child's registry.
13
13
  import { parseToolGrants } from '../internal/command-file.js'
14
14
  import { claudeConfigDir } from '../internal/config-dir.js'
15
- import { installedPlugins } from '../internal/plugins.js'
15
+ import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
16
16
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
17
17
 
18
18
  /**
@@ -353,7 +353,10 @@ function pluginAgentDirs(home: string): Array<{ dir: string; pluginName: string
353
353
  return installedPlugins(home).flatMap((plugin) => {
354
354
  const declared = plugin.manifest.agents
355
355
  const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
356
- return dirs.map((dir) => ({ dir: path.resolve(plugin.root, String(dir)), pluginName: plugin.name }))
356
+ return dirs
357
+ .map((dir) => pluginComponentPath(plugin, String(dir)))
358
+ .filter((dir): dir is string => dir !== undefined)
359
+ .map((dir) => ({ dir, pluginName: plugin.name }))
357
360
  })
358
361
  }
359
362
 
@@ -279,7 +279,9 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
279
279
  run.live = true
280
280
  const killGroup = (signal: NodeJS.Signals): void => {
281
281
  try {
282
- process.kill(-proc.pid!, signal)
282
+ // A child that never spawned has no pid and no group; the direct kill is all there is.
283
+ if (proc.pid) process.kill(-proc.pid, signal)
284
+ else proc.kill(signal)
283
285
  } catch {
284
286
  try {
285
287
  proc.kill(signal)
@@ -395,7 +395,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
395
395
 
396
396
  const killGroup = (sig: NodeJS.Signals): void => {
397
397
  try {
398
- process.kill(-proc.pid!, sig)
398
+ // A child that never spawned has no pid and no group; the direct kill is all there is.
399
+ if (proc.pid) process.kill(-proc.pid, sig)
400
+ else proc.kill(sig)
399
401
  } catch {
400
402
  try {
401
403
  proc.kill(sig)
@@ -1463,6 +1465,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
1463
1465
  // available model list are captured per session so a hook run lands in the right repo.
1464
1466
  let hookCwd = process.cwd()
1465
1467
  let hookModels: ReadonlyArray<{ id: string }> = []
1468
+ // Captured per session like cwd: a named agent resolved for a fork skill or an agent
1469
+ // hook must respect project trust the way the tool path does, or an unapproved repo's
1470
+ // .claude/agents entry (which wins a name clash) would run on its own say-so.
1471
+ let hookAgentScope: AgentScope = 'user'
1466
1472
 
1467
1473
  // Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
1468
1474
  // each file: dozens of fs ops per call. The roster injection below runs every turn
@@ -1474,6 +1480,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1474
1480
  pi.on('session_start', async (_event, ctx) => {
1475
1481
  rosterCache = null
1476
1482
  hookCwd = ctx.cwd
1483
+ hookAgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1477
1484
  try {
1478
1485
  hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
1479
1486
  } catch {
@@ -1485,7 +1492,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1485
1492
  if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
1486
1493
  // A context: fork skill names its agent, or runs with the full toolset;
1487
1494
  // agent hooks keep the read-only hook shape.
1488
- const named = request.agent ? discoverAgents(hookCwd, 'both').agents.find((a) => a.name === request.agent) : undefined
1495
+ const named = request.agent ? discoverAgents(hookCwd, hookAgentScope).agents.find((a) => a.name === request.agent) : undefined
1489
1496
  const agent = named ?? (request.fullTools ? forkAgent(request) : buildHookAgent(request))
1490
1497
  const result = await runSingleAgent({
1491
1498
  defaultCwd: hookCwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",