pi-code 1.0.49 → 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 {
@@ -16,6 +16,10 @@ export interface HookCommand {
16
16
  /** exec-form: spawn `command` directly with these args and no shell (shell-form when
17
17
  * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
18
18
  args?: string[]
19
+ /** Claude's `shell`: "bash" (the default) or "powershell". The default is /bin/sh off
20
+ * Windows; on Windows Git Bash, then PowerShell when Git Bash is absent. Ignored when
21
+ * `args` is set. */
22
+ shell?: string
19
23
  timeout?: number
20
24
  /** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
21
25
  * without blocking its event and with no timeout enforced; `asyncRewake` also runs in
@@ -223,6 +227,44 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
223
227
  * every hook the plugin declared silently vanished. */
224
228
  const jsonEscape = (value: string): string => JSON.stringify(value).slice(1, -1)
225
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
+
226
268
  export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
227
269
  for (const plugin of plugins) {
228
270
  const declared = plugin.manifest.hooks
@@ -230,12 +272,14 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
230
272
  // numeric event keys), so it falls through to the default path rather than
231
273
  // silently registering nothing.
232
274
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
233
- 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}`)
234
277
  continue
235
278
  }
236
- 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
237
281
  try {
238
- 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}`)
239
283
  } catch {
240
284
  // a plugin without hooks contributes nothing
241
285
  }
@@ -72,7 +72,9 @@
72
72
  * agent-frontmatter hooks arrive via PI_CODE_AGENT_HOOKS (Stop pre-converted to
73
73
  * SubagentStop, fired at the child's own agent end) and die with the process.
74
74
  *
75
- * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
75
+ * Hook commands run through the platform shell (`sh -c`; on Windows Git Bash, or
76
+ * PowerShell when Git Bash is absent, see internal/shell-resolve) with the event JSON
77
+ * on stdin. A PreToolUse
76
78
  * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
77
79
  * `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
78
80
  * `{"decision": "block"}`).
@@ -271,7 +273,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
271
273
  if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
272
274
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
273
275
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
274
- return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
276
+ return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
275
277
  }
276
278
  // Claude's `once` (skill-frontmatter hooks only): removed after the first
277
279
  // successful run; a failure, block, or timeout leaves it in place.
@@ -5,10 +5,12 @@
5
5
  */
6
6
 
7
7
  import { type ChildProcess, spawn } from 'node:child_process'
8
+ import * as path from 'node:path'
8
9
  import type { Api, Model } from '@earendil-works/pi-ai'
9
10
  import { runAgent } from '../internal/agent-run.js'
10
11
  import { callMcpTool } from '../internal/mcp-call.js'
11
12
  import { completeText } from '../internal/model-complete.js'
13
+ import { resolveShell } from '../internal/shell-resolve.js'
12
14
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
13
15
 
14
16
  // Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
@@ -41,7 +43,7 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
41
43
  * `args` array it becomes the exec path: `command` is spawned directly with those args.
42
44
  * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
43
45
  * still running at session end can be reaped (Claude kills async hooks at teardown). */
44
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void) => Promise<HookRunResult>
46
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void, shell?: string) => Promise<HookRunResult>
45
47
 
46
48
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
47
49
  const MAX_TIMEOUT_S = 2_147_483
@@ -80,6 +82,15 @@ const TIMEOUT_EXIT_CODE = 124
80
82
  * direct child alone leaves a grandchild alive holding stdout/stderr.
81
83
  */
82
84
  function killTree(child: ChildProcess): void {
85
+ if (process.platform === 'win32') {
86
+ // Windows has no process groups: taskkill /T ends the shell's whole tree. By
87
+ // absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
88
+ // cannot start, the direct kill is all that is left.
89
+ const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
90
+ if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
91
+ else child.kill('SIGKILL')
92
+ return
93
+ }
83
94
  try {
84
95
  // Negative pid targets the whole process group, which `detached` gave the shell.
85
96
  if (child.pid) {
@@ -92,11 +103,19 @@ function killTree(child: ChildProcess): void {
92
103
  child.kill('SIGKILL')
93
104
  }
94
105
 
95
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild) =>
106
+ /** The shell invocation for a shell-form command, or undefined when this machine has no
107
+ * shell for it (Windows with neither Git Bash nor PowerShell). */
108
+ function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
109
+ const resolved = resolveShell(shell)
110
+ return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
111
+ }
112
+
113
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
96
114
  new Promise((resolve) => {
97
- // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
98
- // `detached` makes the shell its own process group leader so the timeout can kill
99
- // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
115
+ // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
116
+ // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
117
+ // Bash lookup. `detached` makes the shell its own process group leader so the
118
+ // timeout can kill the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
100
119
  // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
101
120
  // subprocess Claude spawns, so it is set on the child unconditionally.
102
121
  // CLAUDE_CODE_CHILD_SESSION marks per-call children (hook and status line
@@ -110,11 +129,18 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
110
129
  // An exec-form hook (an `args` array) spawns the executable directly with those args
111
130
  // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
112
131
  // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
113
- // payload survive verbatim). Without args it stays the shell path. Both share the
132
+ // payload survive verbatim). Without args the command string goes to the platform's
133
+ // shell, or to PowerShell when the hook says `shell: "powershell"`. Both share the
114
134
  // same detached process group, so killTree reaches the descendants either way.
115
- const file = Array.isArray(args) ? command : '/bin/sh'
116
- const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
117
- const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
135
+ const target = Array.isArray(args) ? { file: command, spawnArgs: args.map((arg) => substituteArguments(arg, payload)) } : shellInvocation(command, shell)
136
+ if (!target) {
137
+ // Marked like a spawn failure so a gated event fails closed rather than reading as an allow.
138
+ resolve({ code: 0, stdout: '', stderr: 'no shell found: install Git for Windows or PowerShell', timedOut: false, spawnFailed: true })
139
+ return
140
+ }
141
+ // On Windows `detached` means DETACHED_PROCESS, which gives a console child its own
142
+ // window; windowsHide keeps every hook invisible (a no-op elsewhere).
143
+ const child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
118
144
  onChild?.(() => killTree(child))
119
145
  let stdout = ''
120
146
  let stderr = ''
@@ -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 {
@@ -433,26 +433,9 @@ export function powershellQuote(value: string): string {
433
433
  return value.replaceAll(/['‘’‚‛]/g, '$&$&')
434
434
  }
435
435
 
436
- /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
437
- * Windows spellings on win32, where powershell.exe ships with the OS. */
438
- const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
439
-
440
- /** First PowerShell binary found on PATH, or undefined when none is installed. */
441
- export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
442
- const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
443
- for (const candidate of powershellCandidates(platform)) {
444
- for (const dir of dirs) {
445
- const full = path.join(dir, candidate)
446
- try {
447
- fs.accessSync(full, fs.constants.X_OK)
448
- if (fs.statSync(full).isFile()) return full
449
- } catch {
450
- // not here; keep looking
451
- }
452
- }
453
- }
454
- return undefined
455
- }
436
+ import { bashBinary } from './shell-resolve.js'
437
+
438
+ export { resolvePowershellBinary } from './shell-resolve.js'
456
439
 
457
440
  export interface SpanExec {
458
441
  command: string
@@ -464,42 +447,55 @@ export interface SpanExec {
464
447
  mergeStreams?: boolean
465
448
  }
466
449
 
450
+ /** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
451
+ * (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
452
+ * with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
453
+ * comment-only span is a hard sh syntax error (exit 2) that aborted the whole
454
+ * invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
455
+ * group still merges stderr for real spans. */
456
+ function shSpan(binary: string, projectDir: string, script: string): SpanExec {
457
+ const quoted = projectDir.replaceAll("'", String.raw`'\''`)
458
+ return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
459
+ }
460
+
461
+ /** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
462
+ * merge a native command's stderr on a script block, so mergeStreams has the caller
463
+ * append it. The trailing exit forwards a failed native command's code, which pwsh
464
+ * -Command otherwise swallows (the process exited 0 and a failure never aborted the
465
+ * invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
466
+ * Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
467
+ * error text still reaches the model through the merged stderr. */
468
+ function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
469
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
470
+ return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
471
+ }
472
+
467
473
  /**
468
- * The exec invocation for one injected span, honoring the `shell:` frontmatter.
469
- * The default (absent or `bash`) runs through /bin/sh; `powershell` resolves a
470
- * PowerShell binary and runs the span with -Command, falling back to /bin/sh when
471
- * none is installed so the command still works, per Claude's shell matrix. Both
472
- * paths export CLAUDE_PROJECT_DIR (each shell's own quoting) and merge stderr
473
- * into stdout, as the Bash tool does when it runs these for Claude: the sh script
474
- * in-line with 2>&1, the pwsh path via mergeStreams in the caller.
474
+ * The exec invocation for one injected span, honoring the `shell:` frontmatter per
475
+ * Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
476
+ * one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
477
+ * Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
478
+ * command runs ("requires bash"), an undeclared one falls to PowerShell, and with
479
+ * neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
480
+ * shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
481
+ * runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
482
+ * mergeStreams in the caller.
475
483
  *
476
- * The resolver is a parameter rather than a default so the caller passes its own
477
- * imported binding, which keeps the lookup mockable in tests.
484
+ * The resolvers are parameters so a caller (or test) controls the lookups: the
485
+ * PowerShell one is passed as an imported binding, the bash one defaults to the
486
+ * platform rule.
478
487
  */
479
- export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
488
+ export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
480
489
  if (shell === 'powershell') {
481
490
  const binary = resolveBinary()
482
- if (binary !== undefined) {
483
- // CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
484
- // it is exported in the script alongside CLAUDE_PROJECT_DIR.
485
- const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
486
- // No in-script 2>&1: under pwsh 7 it does not merge a native command's
487
- // stderr on a script block, so mergeStreams has the caller append it. The
488
- // trailing exit forwards a failed native command's code, which pwsh
489
- // -Command otherwise swallows (the process exited 0 and a failure never
490
- // aborted the invocation). An empty or cmdlet-only span leaves
491
- // $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
492
- // sets no exit code, so it cannot abort; its error text still reaches the
493
- // model through the merged stderr.
494
- return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
495
- }
491
+ if (binary !== undefined) return powershellSpan(binary, projectDir, script)
496
492
  }
497
- const quoted = projectDir.replaceAll("'", String.raw`'\''`)
498
- // The group opens with a `:` null command: `{ }` around an empty or
499
- // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
500
- // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
501
- // while the group still merges stderr for real spans.
502
- return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
493
+ const bash = resolveBash()
494
+ if (bash !== undefined) return shSpan(bash, projectDir, script)
495
+ if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
496
+ const binary = resolveBinary()
497
+ if (binary !== undefined) return powershellSpan(binary, projectDir, script)
498
+ throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
503
499
  }
504
500
 
505
501
  interface FenceBlock {
@@ -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))
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Which shell runs a command string, per Claude's contract. hooks.md: a shell-form
3
+ * command is "passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or
4
+ * PowerShell when Git Bash isn't installed", and a hook's `shell: "powershell"` runs it
5
+ * via PowerShell (pwsh, then Windows PowerShell 5.1). statusline.md applies the same
6
+ * Git Bash-then-PowerShell rule to statusLine commands. troubleshoot-install.md: when
7
+ * CLAUDE_CODE_GIT_BASH_PATH is unset, Git Bash is looked for in `C:\Program Files\Git`
8
+ * and `C:\Program Files (x86)\Git`, then through the `git` on PATH (the `bin\bash.exe`
9
+ * of that installation), skipping a git in the launch directory or below it under
10
+ * node_modules or a virtual environment. env-vars.md: the override is ignored unless
11
+ * the file exists and is named bash.exe, sh.exe, bash, or sh.
12
+ */
13
+
14
+ import * as fs from 'node:fs'
15
+ import * as path from 'node:path'
16
+
17
+ export interface ResolvedShell {
18
+ kind: 'bash' | 'powershell'
19
+ file: string
20
+ /** The argv that runs `command` through this shell. */
21
+ argsFor: (command: string) => string[]
22
+ }
23
+
24
+ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh'])
25
+ const DEFAULT_GIT_ROOTS = [String.raw`C:\Program Files\Git`, String.raw`C:\Program Files (x86)\Git`]
26
+ const PROJECT_TOOLING_DIRS = new Set(['node_modules', '.venv', 'venv'])
27
+
28
+ const isFile = (file: string): boolean => {
29
+ try {
30
+ return fs.statSync(file).isFile()
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
37
+ * Windows spellings on win32, where powershell.exe ships with the OS. */
38
+ const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
39
+
40
+ /** First PowerShell binary found on PATH, or undefined when none is installed. */
41
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
42
+ const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
43
+ for (const candidate of powershellCandidates(platform)) {
44
+ for (const dir of dirs) {
45
+ const full = path.join(dir, candidate)
46
+ try {
47
+ fs.accessSync(full, fs.constants.X_OK)
48
+ if (fs.statSync(full).isFile()) return full
49
+ } catch {
50
+ // not here; keep looking
51
+ }
52
+ }
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ /** A git in the launch directory itself, or below it under node_modules or a virtual
58
+ * environment, is a project's own tooling rather than the user's Git installation. */
59
+ function isProjectTooling(dir: string, cwd: string): boolean {
60
+ const relative = path.relative(cwd, dir)
61
+ if (relative === '') return true
62
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return false
63
+ return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
64
+ }
65
+
66
+ /** Git Bash in Claude's documented order, or undefined when none is installed.
67
+ * `installRoots` is the default install location list, a parameter so tests on a
68
+ * Windows host that has Git there can still exercise the later rules. */
69
+ export function resolveGitBash(env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots: string[] = DEFAULT_GIT_ROOTS): string | undefined {
70
+ const override = env.CLAUDE_CODE_GIT_BASH_PATH
71
+ if (override && GIT_BASH_NAMES.has(path.basename(override).toLowerCase()) && isFile(override)) return override
72
+ for (const root of installRoots) {
73
+ const bash = path.join(root, 'bin', 'bash.exe')
74
+ if (isFile(bash)) return bash
75
+ }
76
+ for (const dir of (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)) {
77
+ if (!isFile(path.join(dir, 'git.exe')) || isProjectTooling(dir, cwd)) continue
78
+ // Git for Windows puts git.exe in cmd\ (or bin\); bash.exe lives in bin\ of the same install.
79
+ const bash = path.join(path.dirname(dir), 'bin', 'bash.exe')
80
+ if (isFile(bash)) return bash
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ /** Claude rewrites these three placeholders in a PowerShell shell-form command to
86
+ * PowerShell's `${env:NAME}` form; the bare `$NAME` spelling is left alone (PowerShell
87
+ * reads it as an undefined variable, which Claude only warns about). */
88
+ export const toPowershellPlaceholders = (command: string): string => command.replace(/\$\{(CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA)\}/g, (_match, name: string) => `\${env:${name}}`)
89
+
90
+ /** The bash for an injected command span: /bin/sh off Windows, Git Bash on Windows
91
+ * (undefined when it is not installed). */
92
+ export function bashBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): string | undefined {
93
+ return platform === 'win32' ? resolveGitBash(env, cwd, installRoots) : '/bin/sh'
94
+ }
95
+
96
+ const bashShell = (file: string): ResolvedShell => ({ kind: 'bash', file, argsFor: (command) => ['-c', command] })
97
+
98
+ const powershellShell = (file: string): ResolvedShell => ({
99
+ kind: 'powershell',
100
+ file,
101
+ // -Command swallows a native command's exit code unless it is forwarded; a script
102
+ // that calls `exit` itself never reaches the trailer, so its own code stands.
103
+ argsFor: (command) => ['-NoProfile', '-NonInteractive', '-Command', `${toPowershellPlaceholders(command)}\nexit $LASTEXITCODE`],
104
+ })
105
+
106
+ /**
107
+ * The shell for a command string. `preferred` is a hook's `shell` field: "powershell"
108
+ * runs through PowerShell where one is installed. Otherwise /bin/sh off Windows; on
109
+ * Windows Git Bash, then PowerShell. Undefined means nothing on this machine can run
110
+ * it (Windows with neither Git Bash nor PowerShell installed).
111
+ */
112
+ export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
113
+ if (preferred === 'powershell') {
114
+ const powershell = resolvePowershellBinary(platform, env)
115
+ if (powershell) return powershellShell(powershell)
116
+ }
117
+ const bash = bashBinary(platform, env, cwd, installRoots)
118
+ if (bash) return bashShell(bash)
119
+ const powershell = resolvePowershellBinary(platform, env)
120
+ return powershell ? powershellShell(powershell) : undefined
121
+ }
@@ -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)
@@ -16,6 +16,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
16
16
  import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'
17
17
  import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
18
18
  import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
19
+ import { resolveShell } from '../internal/shell-resolve.js'
19
20
  import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
20
21
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
21
22
 
@@ -259,8 +260,13 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
259
260
  const token = resolveBearerToken(config)
260
261
  if (token) headers.Authorization = `Bearer ${token}`
261
262
  // A headersHelper generates connect-time headers for non-OAuth auth schemes; its
262
- // JSON stdout merges over the static headers.
263
- 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)))
264
270
  warnMissing()
265
271
  // Claude: a configured Authorization header, whether static, a bearer token, or
266
272
  // helper output, is the server's authentication; there is no OAuth fallback for it.
@@ -306,11 +312,17 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
306
312
  }
307
313
 
308
314
  /** Run a headersHelper command and parse its JSON stdout into headers, under the
309
- * environment helperEnv built. A failure or a 10s timeout yields no extra headers
310
- * rather than blocking the connection. */
315
+ * environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
316
+ * or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
317
+ * yields no extra headers rather than blocking the connection. */
311
318
  function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
312
319
  return new Promise((resolve) => {
313
- execFile('/bin/sh', ['-c', command], { timeout: 10_000, env }, (error, stdout) => {
320
+ const shell = resolveShell(undefined)
321
+ if (!shell) {
322
+ resolve({})
323
+ return
324
+ }
325
+ execFile(shell.file, shell.argsFor(command), { timeout: 10_000, env }, (error, stdout) => {
314
326
  resolve(error ? {} : parseHelperHeaders(stdout))
315
327
  })
316
328
  })
@@ -360,7 +372,7 @@ async function connectHttpFamily(name: string, config: { url: string; oauth?: OA
360
372
  // dynamic registration, keeping it bound to the real callback port. A
361
373
  // pre-configured client (oauth.clientId) rides the silent provider too, so its
362
374
  // stored tokens refresh with the configured credentials.
363
- const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth)
375
+ const silent = hasConfiguredAuth ? undefined : new FileOAuthProvider(name, () => {}, config.oauth, config.url)
364
376
  try {
365
377
  const client = newClient()
366
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.49",
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",