pi-code 1.0.61 → 1.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,8 @@ Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/
60
60
 
61
61
  pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
62
62
 
63
+ Trust is the other place pi-code is deliberately stricter. Claude states that "a `claude -p` run never shows the trust dialog" and loads the project's hooks, MCP servers, agents, commands, skills and rules anyway. pi-code refuses instead: with no stored decision and no UI to ask, a headless run in a project you have not already trusted loads none of them. A repository would otherwise get to run its own hooks and MCP servers in any CI job that checks it out, with nobody present to decline.
64
+
63
65
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
64
66
 
65
67
  [`extensions/internal/`](extensions/internal) holds the shared modules pi's loader must not treat as extensions (each file's header says what it owns); only `internal/` keeps them out of pi's extension scan.
@@ -10,8 +10,8 @@
10
10
  * `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
11
11
  * session model for the command's run via `pi.setModel`, restored on agent_end).
12
12
  * `shell: powershell` runs a command's injected spans through PowerShell when a
13
- * pwsh binary is installed, falling back to /bin/sh so the command still works
14
- * without one.
13
+ * pwsh binary is installed, falling back to the default shell path so the command
14
+ * still works without one: /bin/sh on POSIX, Git Bash on Windows.
15
15
  *
16
16
  * Commands are also exposed to the model through a `slash_command` tool
17
17
  * (Claude's SlashCommand tool), listing every discovered command whose file
@@ -33,7 +33,7 @@
33
33
  * A project command body is repository-controlled text that can run shell
34
34
  * commands and read files, so project commands load only once the project is
35
35
  * approved. That closes the "skills / commands are not trust-gated" limitation
36
- * for commands; skills remain pi-loader territory.
36
+ * for commands; skills.ts gates project skills on the same approval.
37
37
  *
38
38
  * Docs: https://code.claude.com/docs/en/slash-commands.md
39
39
  */
@@ -54,6 +54,7 @@ import { matchesPathRules } from './internal/path-rules.js'
54
54
  import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
55
55
  import { isProjectApproved } from './internal/project-approval.js'
56
56
  import { ancestorDirs, repoRoot } from './internal/project-root.js'
57
+ import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
57
58
  import { claudeSettingsChain } from './internal/settings-chain.js'
58
59
  import { createTurnOverride } from './internal/turn-override.js'
59
60
  import { errorMessage, isDirectory } from './internal/values.js'
@@ -85,6 +86,15 @@ function substitutePathRule(rule: string, vars: Record<string, string | undefine
85
86
  return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
86
87
  }
87
88
 
89
+ /** One scalar argument scope checked against one call: undefined when the tool is
90
+ * unscoped for this run or the call matches, otherwise a block naming the rules in
91
+ * force and the value that failed them. */
92
+ function scopeVerdict(rules: string[] | undefined, tool: string, subjectLabel: string, subject: string, matches: (rules: string[]) => boolean): { block: true; reason: string } | undefined {
93
+ if (!rules) return undefined
94
+ if (matches(rules)) return undefined
95
+ return { block: true, reason: `allowed-tools: ${tool} is scoped for this command.\nAllowed: ${rules.join(', ')}\n${subjectLabel}: ${subject}` }
96
+ }
97
+
88
98
  /** The path rules that survive an allowed-tools intersection: only the tools the
89
99
  * grant kept get their scopes, each rule ${CLAUDE_*}-substituted like the body. */
90
100
  function scopedPathRules(pathRules: Partial<Record<PathRuleTool, string[]>>, granted: string[], vars: Record<string, string | undefined>): Partial<Record<PathRuleTool, string[]>> {
@@ -310,6 +320,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
310
320
  let pendingRestore: string[] | undefined
311
321
  /** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
312
322
  let pendingBashRules: string[] | undefined
323
+ /** `WebFetch(domain:...)` scopes enforced the same way. */
324
+ let pendingDomainRules: string[] | undefined
325
+ /** `Agent(...)`/`Task(...)` agent-name scopes enforced the same way. */
326
+ let pendingAgentRules: string[] | undefined
327
+ /** `Skill(...)` name-pattern scopes enforced the same way. */
328
+ let pendingSkillRules: string[] | undefined
313
329
  /** Read/Edit path scopes enforced the same way, per pi file tool. */
314
330
  let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
315
331
  /** The session model to restore after a command's `model:` override drove its run,
@@ -342,6 +358,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
342
358
  // continuation remains, which is the grant's true clearing point.
343
359
  pi.on('agent_settled', async () => {
344
360
  pendingBashRules = undefined
361
+ pendingDomainRules = undefined
362
+ pendingAgentRules = undefined
363
+ pendingSkillRules = undefined
345
364
  pendingPathRules = undefined
346
365
  modelOverride.settle()
347
366
  effortOverride.settle()
@@ -354,18 +373,27 @@ export default function commandsExtension(pi: ExtensionAPI) {
354
373
  // The active-tool set has no argument dimension, so a scoped grant hands the turn
355
374
  // the whole tool; the scope is enforced here instead, when the call arrives. Same
356
375
  // steering-not-sandbox caveat as plan mode's guard.
376
+ //
377
+ // PAIRED WITH hooks/matcher.ts's matchesToolPattern, which dispatches the same tools
378
+ // to the same matchers for hook `if` filters. Deliberately not merged: bash differs
379
+ // (here every segment of a compound command must match a rule; there the filter is
380
+ // best effort and errs toward running the hook). A new scoped tool must be added in
381
+ // both; the shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
357
382
  pi.on('tool_call', async (event, ctx) => {
358
- if (pendingBashRules && event.toolName === 'bash') {
359
- const command = typeof event.input.command === 'string' ? event.input.command : ''
360
- if (matchesBashRules(command, pendingBashRules)) return
361
- return {
362
- block: true,
363
- reason: `allowed-tools: bash is scoped for this command.\nAllowed: ${pendingBashRules.join(', ')}\nCommand: ${command}`,
364
- }
383
+ const input = event.input as Record<string, unknown>
384
+ const text = (value: unknown): string => (typeof value === 'string' ? value : '')
385
+ // The four scalar-scope tools share one shape: rules in force, the part of the call
386
+ // they judge, and the sentence naming both when it blocks. One helper keeps the arms
387
+ // from drifting; only the subject and the matcher differ.
388
+ if (event.toolName === 'bash') return scopeVerdict(pendingBashRules, 'bash', 'Command', text(input.command), (rules) => matchesBashRules(text(input.command), rules))
389
+ if (event.toolName === 'web_fetch') return scopeVerdict(pendingDomainRules, 'web_fetch', 'Url', text(input.url), (rules) => matchesDomainRules(text(input.url), rules))
390
+ if (event.toolName === 'subagent') {
391
+ const names = agentNamesIn(input)
392
+ return scopeVerdict(pendingAgentRules, 'subagent', 'Agents', names.join(', '), (rules) => matchesAgentRules(names, rules))
365
393
  }
394
+ if (event.toolName === 'slash_command') return scopeVerdict(pendingSkillRules, 'slash_command', 'Command', text(input.command), (rules) => matchesSkillRules(text(input.command), rules))
366
395
  const rules = pendingPathRules?.[event.toolName as PathRuleTool]
367
396
  if (!rules) return
368
- const input = event.input as Record<string, unknown>
369
397
  const filePath = typeof input.path === 'string' ? input.path : ''
370
398
  const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
371
399
  if (filePath && matchesPathRules(filePath, rules, anchors)) return
@@ -403,6 +431,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
403
431
  // the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
404
432
  // script by its real path, as the skills docs show.
405
433
  pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
434
+ pendingDomainRules = granted.includes('web_fetch') ? parsed.domainRules : undefined
435
+ pendingAgentRules = granted.includes('subagent') ? parsed.agentRules : undefined
436
+ pendingSkillRules = granted.includes('slash_command') ? parsed.skillRules : undefined
406
437
  pendingPathRules = parsed.pathRules ? scopedPathRules(parsed.pathRules, granted, vars) : undefined
407
438
  }
408
439
 
@@ -482,6 +513,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
482
513
  // no setActiveTools/setModel/setThinkingLevel, since the new session owns its own state.
483
514
  pendingRestore = undefined
484
515
  pendingBashRules = undefined
516
+ pendingDomainRules = undefined
517
+ pendingAgentRules = undefined
518
+ pendingSkillRules = undefined
485
519
  pendingPathRules = undefined
486
520
  modelOverride.reset()
487
521
  effortOverride.reset()
@@ -943,22 +943,25 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
943
943
  type: 'string',
944
944
  })
945
945
 
946
- pi.on('session_start', async (_event, ctx) => {
947
- // pi's ctx.reload() rebuilds extension instances, so this set never survives
948
- // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
949
- // which is fine, since a reload re-loads the instruction files.
950
- announced.clear()
951
- envCache = undefined
952
- importMemo = undefined
953
- localContexts = []
954
- userContext = undefined
955
- projectDotClaude = undefined
946
+ /** The session-scope memory session_start loads: the user's own CLAUDE.md, plus
947
+ * CLAUDE.local.md and ./.claude/CLAUDE.md from an approved project. Claude:
948
+ * CLAUDE_CODE_DISABLE_CLAUDE_MDS "prevent[s] loading any CLAUDE.md memory files
949
+ * into context, including user, project, and auto memory files", so a disabled run
950
+ * returns empty state and skips isProjectApproved's trust prompt entirely: there is
951
+ * nothing left for it to gate. Extracted so session_start itself stays a thin
952
+ * dispatcher; before_agent_start's own excluded() check covers pi's native files,
953
+ * which this cannot reach since pi loads those itself. */
954
+ async function loadSessionMemory(ctx: ExtensionContext): Promise<{ userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } }> {
955
+ if (process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1') return { localContexts: [] }
956
956
 
957
957
  // ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
958
958
  // project approval is required; a missing file simply leaves it unset.
959
959
  const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
960
960
  const userContent = readContextFile(userClaudeMd)
961
- if (userContent !== undefined) userContext = { path: userClaudeMd, content: userContent }
961
+ const memory: { userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } } = {
962
+ userContext: userContent !== undefined ? { path: userClaudeMd, content: userContent } : undefined,
963
+ localContexts: [],
964
+ }
962
965
 
963
966
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
964
967
  // skips it. A cloned repo can ship one, so it is gated like other project config.
@@ -969,16 +972,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
969
972
  // ride the one approval decision.
970
973
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
971
974
  const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
972
- if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
973
- for (const candidate of candidates) {
974
- const content = readContextFile(candidate)
975
- if (content !== undefined) localContexts.push({ path: candidate, content })
976
- }
977
- if (dotClaudeMd !== null) {
978
- const content = readContextFile(dotClaudeMd)
979
- if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
980
- }
975
+ if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
976
+
977
+ for (const candidate of candidates) {
978
+ const content = readContextFile(candidate)
979
+ if (content !== undefined) memory.localContexts.push({ path: candidate, content })
981
980
  }
981
+ if (dotClaudeMd !== null) {
982
+ const content = readContextFile(dotClaudeMd)
983
+ if (content !== undefined) memory.projectDotClaude = { path: dotClaudeMd, content }
984
+ }
985
+ return memory
986
+ }
987
+
988
+ pi.on('session_start', async (_event, ctx) => {
989
+ // pi's ctx.reload() rebuilds extension instances, so this set never survives
990
+ // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
991
+ // which is fine, since a reload re-loads the instruction files.
992
+ announced.clear()
993
+ envCache = undefined
994
+ importMemo = undefined
995
+ const memory = await loadSessionMemory(ctx)
996
+ localContexts = memory.localContexts
997
+ userContext = memory.userContext
998
+ projectDotClaude = memory.projectDotClaude
982
999
  // Read after the local-context flow so an approval it just recorded is honored.
983
1000
  projectApproved = isProjectApprovedSilently(ctx)
984
1001
  gatedFilesApproved = isGatedFileApproved(ctx)
@@ -1014,7 +1031,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1014
1031
  envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
1015
1032
  }
1016
1033
  const { managed, excludeGlobs, projectRoot } = envCache
1017
- const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
1034
+ // CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
1035
+ // files ("including... auto memory files"), which session_start's gate cannot reach
1036
+ // since pi loads them itself. Routing through the exclusion path already used for
1037
+ // claudeMdExcludes drops the block, strips the InstructionsLoaded event, and skips
1038
+ // import expansion for it, exactly as an excluded file already does.
1039
+ const excluded = (absPath: string): boolean => process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1' || isExcludedPath(absPath, excludeGlobs, home)
1018
1040
 
1019
1041
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
1020
1042
  // from import expansion; surviving blocks get block-level comments stripped.
@@ -40,6 +40,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
40
40
  import { readManagedSettings } from './internal/managed-settings.js'
41
41
  import { isProjectApprovedSilently } from './internal/project-approval.js'
42
42
  import { claudeSettingsChain } from './internal/settings-chain.js'
43
+ import { watchSettingsFiles } from './internal/settings-watch.js'
43
44
  import { isRecord } from './internal/values.js'
44
45
 
45
46
  /** The `env` object of one settings scope, coerced to string values. A string is kept
@@ -145,6 +146,8 @@ function projectEnv(cwd: string, home: string): Record<string, string> {
145
146
 
146
147
  export default function envSettingsExtension(pi: ExtensionAPI) {
147
148
  const owned = new Map<string, string | undefined>()
149
+ /** Stops the watcher of the previous session, as the hooks extension does. */
150
+ let disposeWatch: () => void = () => {}
148
151
 
149
152
  const apply = (home: string, project: Record<string, string>): void => {
150
153
  applyEnvSettings(mergeEnvScopes(envFromSettings(readManagedSettings()), userEnv(home), project), process.env, owned)
@@ -155,6 +158,21 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
155
158
  apply(os.homedir(), {})
156
159
 
157
160
  pi.on('session_start', async (_event, ctx: ExtensionContext) => {
158
- apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd, os.homedir()) : {})
161
+ const home = os.homedir()
162
+ const approved = isProjectApprovedSilently(ctx)
163
+ const reapply = (): void => apply(home, approved ? projectEnv(ctx.cwd, home) : {})
164
+ reapply()
165
+ // Claude: "Claude Code watches your settings files and reloads them when they change,
166
+ // so it applies most edits to the running session without a restart." `env` is not
167
+ // one of the restart-only keys (model, effortLevel/modelSettings, outputStyle), so an
168
+ // edit has to reach process.env now rather than at the next session. applyEnvSettings
169
+ // tracks what it owns, so a key removed from the file is restored, not left behind.
170
+ disposeWatch()
171
+ disposeWatch = watchSettingsFiles(claudeSettingsChain(ctx.cwd, home, approved), reapply)
172
+ })
173
+
174
+ pi.on('session_shutdown', async () => {
175
+ disposeWatch()
176
+ disposeWatch = () => {}
159
177
  })
160
178
  }
@@ -331,6 +331,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
331
331
  pi.on('turn_start', async () => {
332
332
  if (!runNeedsSnapshot) return
333
333
  runNeedsSnapshot = false
334
+ // Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
335
+ // able to restore code changes." No snapshot means turn_end's `if (!snap) return`
336
+ // always fires, so no checkpoint is ever recorded.
337
+ if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
334
338
  pending = await snapshot()
335
339
  })
336
340
 
@@ -10,6 +10,14 @@
10
10
  * node_modules/@earendil-works/pi-coding-agent/dist/core/tools and Claude's hooks
11
11
  * reference (per-tool input tables, PostToolUse response shapes).
12
12
  *
13
+ * NAMES and SHAPES cover different sets on purpose. Names come from
14
+ * internal/claude-tool-names.ts and cover every pi tool the tools reference has a
15
+ * counterpart for, because a hook matcher written in Claude's vocabulary has to fire.
16
+ * Shapes are translated only for the file and shell tools below, because those are
17
+ * the only tools the hooks reference gives a per-tool input table for; for the rest
18
+ * there is no documented Claude shape to conform to, so the pi input passes through
19
+ * unchanged and a hook reads the fields pi's own schema defines.
20
+ *
13
21
  * Translation choices the shapes force, each documented on its function:
14
22
  * - pi's multi-entry `edits[]` maps to Claude's single Edit with the first entry in
15
23
  * `old_string`/`new_string` and the full array carried alongside as `edits`.
@@ -21,13 +29,7 @@
21
29
  import * as os from 'node:os'
22
30
  import * as path from 'node:path'
23
31
 
24
- /** pi built-in -> Claude tool name for hook payloads and matchers. MCP tools ride
25
- * the alias bus instead; pi tools with no Claude counterpart (ls) stay untranslated. */
26
- const CLAUDE_NAMES: Record<string, string> = { bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', find: 'Glob' }
27
-
28
- export function claudeToolName(piName: string): string | undefined {
29
- return CLAUDE_NAMES[piName]
30
- }
32
+ export { claudeToolName } from '../internal/claude-tool-names.js'
31
33
 
32
34
  /** Claude file-tool paths are always absolute with `~` expanded before hooks run,
33
35
  * so a path guard cannot be bypassed by a relative or `~` spelling of the same path. */
@@ -33,6 +33,11 @@ export interface HookCommand {
33
33
  /** Claude's permission-rule filter (`"Bash(git *)"`, `"Edit(*.ts)"`): evaluated only
34
34
  * on tool events; on any other event a hook carrying `if` never runs. */
35
35
  if?: string
36
+ /** The declaring plugin's paths, exported to the hook process. Claude: "All three
37
+ * are exported as environment variables to hook processes and to MCP and LSP server
38
+ * subprocesses", so a script can read them instead of relying on inline substitution. */
39
+ pluginRoot?: string
40
+ pluginDataDir?: string
36
41
  /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
37
42
  url?: string
38
43
  headers?: Record<string, string>
@@ -181,7 +186,18 @@ function stampOrigin(entries: HookMatcher[], origin: string): void {
181
186
  }
182
187
  }
183
188
 
184
- function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string): void {
189
+ /** Stamp the declaring plugin's paths onto every hook it contributes, so the runner
190
+ * can export them without re-deriving which plugin a hook came from. */
191
+ function stampPluginPaths(entries: HookMatcher[], plugin: { root: string; dataDir: string }): void {
192
+ for (const entry of entries) {
193
+ for (const hook of entry.hooks ?? []) {
194
+ hook.pluginRoot = plugin.root
195
+ hook.pluginDataDir = plugin.dataDir
196
+ }
197
+ }
198
+ }
199
+
200
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string, plugin?: { root: string; dataDir: string }): void {
185
201
  let parsed: { hooks?: HooksConfig }
186
202
  try {
187
203
  parsed = JSON.parse(raw)
@@ -200,6 +216,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
200
216
  const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
201
217
  if (usable.length === 0) continue
202
218
  if (origin !== undefined) stampOrigin(usable, origin)
219
+ if (plugin !== undefined) stampPluginPaths(usable, plugin)
203
220
  config[event] = [...(config[event] ?? []), ...usable]
204
221
  // Each parse produces fresh entry objects, so object identity keys the /hooks
205
222
  // viewer's source attribution without touching the entries themselves.
@@ -261,13 +278,13 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
261
278
  // silently registering nothing.
262
279
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
263
280
  const inlineSource = `${plugin.name} (plugin.json)`
264
- mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`)
281
+ mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(JSON.stringify({ hooks: declared }), inlineSource), plugin, jsonEscape), inlineSource, sources, `plugin:${plugin.name}`, plugin)
265
282
  continue
266
283
  }
267
284
  const file = pluginComponentPath(plugin, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
268
285
  if (file === undefined) continue
269
286
  try {
270
- mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`)
287
+ mergeHooksJson(config, substitutePluginVars(withoutUserConfigShellCommands(fs.readFileSync(file, 'utf-8'), file), plugin, jsonEscape), file, sources, `plugin:${plugin.name}`, plugin)
271
288
  } catch {
272
289
  // a plugin without hooks contributes nothing
273
290
  }
@@ -102,7 +102,7 @@ import { readManagedSettings } from '../internal/managed-settings.js'
102
102
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
103
103
  import { resolveModelOverride } from '../internal/model-lookup.js'
104
104
  import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
105
- import { installedPlugins } from '../internal/plugins.js'
105
+ import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
106
106
  import { isProjectApproved } from '../internal/project-approval.js'
107
107
  import { repoRoot } from '../internal/project-root.js'
108
108
  import { watchSettingsFiles } from '../internal/settings-watch.js'
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
283
283
  if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
284
284
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
285
285
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
286
- return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild, hook.shell)
286
+ return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined, sessionId: merged.session_id as string | undefined })
287
287
  }
288
288
  // Claude's `once` (skill-frontmatter hooks only): removed after the first
289
289
  // successful run; a failure, block, or timeout leaves it in place.
@@ -414,7 +414,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
414
414
  // Claude's allowManagedHooksOnly: user, project, local, plugin, and skill
415
415
  // hooks are blocked; only the managed set runs.
416
416
  managedHooksOnly = managedSettings.allowManagedHooksOnly === true
417
- if (managedHooksOnly || readSettingsDisableAllHooks(files)) return
417
+ // The escape hatch is checked FIRST, and deliberately: a settings-level
418
+ // disableAllHooks turns off every non-managed hook, and a plugin's hooks are
419
+ // non-managed however the plugin came to be enabled. Only the managed policy hooks
420
+ // loaded above survive it. Putting the force-enable exemption ahead of this let an
421
+ // administrator's enabledPlugins entry defeat the user's own escape hatch.
422
+ if (readSettingsDisableAllHooks(files)) return
423
+ if (managedHooksOnly) {
424
+ // Claude: "Hooks from plugins force-enabled in managed settings `enabledPlugins`
425
+ // are exempt." That exemption is from allowManagedHooksOnly, not from the hatch.
426
+ loadPluginHooks(config, managedForceEnabled(installedPlugins(os.homedir())), hookSources)
427
+ return
428
+ }
418
429
  for (const [eventName, matchers] of Object.entries(loadHooks(files, hookSources))) config[eventName] = [...(config[eventName] ?? []), ...matchers]
419
430
  // Plugins are user-installed and enabled by user settings (see installedPlugins),
420
431
  // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import { matchesBashIfFilter } from '../internal/bash-rules.js'
8
+ import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
8
9
  import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
10
+ import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
9
11
  import { errorMessage } from '../internal/values.js'
10
12
  import type { HookCommand, HookMatcher } from './config.js'
11
13
 
@@ -179,13 +181,39 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
179
181
  if (!toolMatches) return false
180
182
  const pattern = parsed[2]
181
183
  if (pattern === undefined) return true
182
- const input = target.input as Record<string, unknown> | null
183
- if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
184
- const command = typeof input?.command === 'string' ? input.command : ''
185
- return command.length > 0 && matchesBashIfFilter(command, pattern)
184
+ // Either spelling can arrive; normalize to the pi name the specifier engines take.
185
+ const tool = CLAUDE_TOOL_MAP[fold(target.piName)] ?? CLAUDE_TOOL_MAP[fold(target.claudeName ?? '')] ?? fold(target.piName)
186
+ return matchesToolPattern(tool, target.input as Record<string, unknown> | null, pattern, target.anchors)
187
+ }
188
+
189
+ /** One `if` pattern against a call's arguments. Claude evaluates the rule "against
190
+ * the tool name and arguments together" in permission-rule syntax, so each tool uses
191
+ * the same specifier engine its permission rules use:
192
+ *
193
+ * PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
194
+ * same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
195
+ * differs on purpose (an allow scope requires every segment to match, an `if` filter is
196
+ * best effort and errs toward running the hook), and merging would hide that. They are
197
+ * two lists that must agree, so a new scoped tool has to be added in both places; the
198
+ * shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
199
+ * a command pattern for Bash, a
200
+ * `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
201
+ * path rule for the file tools. A tool with no specifier syntax matches nothing,
202
+ * which is also what an unparseable rule does. */
203
+ function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
204
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '')
205
+ switch (piName) {
206
+ case 'bash':
207
+ return str(input?.command).length > 0 && matchesBashIfFilter(str(input?.command), pattern)
208
+ case 'web_fetch':
209
+ return matchesDomainRules(str(input?.url), [pattern])
210
+ case 'subagent':
211
+ return matchesAgentRules(agentNamesIn(input), [pattern])
212
+ case 'slash_command':
213
+ return matchesSkillRules(str(input?.command), [pattern])
214
+ default: {
215
+ const filePath = str(input?.path) || str(input?.file_path)
216
+ return filePath.length > 0 && matchesPathRules(filePath, [pattern], anchors)
217
+ }
186
218
  }
187
- let filePath = ''
188
- if (typeof input?.path === 'string') filePath = input.path
189
- else if (typeof input?.file_path === 'string') filePath = input.file_path
190
- return filePath.length > 0 && matchesPathRules(filePath, [pattern], target.anchors)
191
219
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { type ChildProcess, spawn } from 'node:child_process'
8
+ import * as fs from 'node:fs'
8
9
  import * as path from 'node:path'
9
10
  import type { Api, Model } from '@earendil-works/pi-ai'
10
11
  import { runAgent } from '../internal/agent-run.js'
@@ -44,7 +45,24 @@ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number
44
45
  * `args` array it becomes the exec path: `command` is spawned directly with those args.
45
46
  * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
46
47
  * still running at session end can be reaped (Claude kills async hooks at teardown). */
47
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void, shell?: string) => Promise<HookRunResult>
48
+ /** How one command hook is spawned, beyond the payload and its budget. Grouped rather
49
+ * than trailing off the parameter list: the exec form, the shell choice and the
50
+ * declaring plugin are all per-hook fields that arrive together from one HookCommand. */
51
+ export interface HookSpawnOptions {
52
+ projectDir?: string
53
+ /** exec-form argv; shell-form when absent. */
54
+ args?: string[]
55
+ onChild?: (kill: () => void) => void
56
+ shell?: string
57
+ /** The declaring plugin's paths, exported to the child. */
58
+ plugin?: { root: string; dataDir: string }
59
+ /** Claude: "set automatically to the current session ID in ... hook command
60
+ * subprocesses ... this matches the session_id field in the hook JSON input and
61
+ * is updated on /clear." Absent in a stub context with no session manager. */
62
+ sessionId?: string
63
+ }
64
+
65
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
48
66
 
49
67
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
50
68
  const MAX_TIMEOUT_S = 2_147_483
@@ -104,6 +122,17 @@ function killTree(child: ChildProcess): void {
104
122
  child.kill('SIGKILL')
105
123
  }
106
124
 
125
+ /** Create the plugin data directory the moment its path is handed to a child. Claude
126
+ * describes it as "created on first reference"; a best-effort mkdir, since a hook whose
127
+ * data dir cannot be created should still run. */
128
+ function ensureDir(dir: string): void {
129
+ try {
130
+ fs.mkdirSync(dir, { recursive: true })
131
+ } catch {
132
+ // The hook runs anyway; a script that needs the directory reports its own failure.
133
+ }
134
+ }
135
+
107
136
  /** The shell invocation for a shell-form command, or undefined when this machine has no
108
137
  * shell for it (Windows with neither Git Bash nor PowerShell). */
109
138
  function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
@@ -111,7 +140,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
111
140
  return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
112
141
  }
113
142
 
114
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
143
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
115
144
  new Promise((resolve) => {
116
145
  // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
117
146
  // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
@@ -125,6 +154,20 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
125
154
  // detection cannot see the captured terminal.
126
155
  const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
127
156
  if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
157
+ // Claude: "Claude Code sets this to its own process ID in the subprocesses it
158
+ // spawns: Bash and PowerShell tool commands and hook commands." Set unconditionally,
159
+ // since every hook child qualifies.
160
+ env.CLAUDE_PID = String(process.pid)
161
+ if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
162
+ // Claude: "All three are exported as environment variables to hook processes and to
163
+ // MCP and LSP server subprocesses", so a plugin script can read them rather than
164
+ // depend on inline substitution. The data directory is "created on first reference",
165
+ // and exporting the path is that reference: a script should not have to mkdir it.
166
+ if (plugin) {
167
+ env.CLAUDE_PLUGIN_ROOT = plugin.root
168
+ env.CLAUDE_PLUGIN_DATA = plugin.dataDir
169
+ ensureDir(plugin.dataDir)
170
+ }
128
171
  if (process.stdout.columns) env.COLUMNS = String(process.stdout.columns)
129
172
  if (process.stdout.rows) env.LINES = String(process.stdout.rows)
130
173
  // An exec-form hook (an `args` array) spawns the executable directly with those args
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The one place pi tool names and Claude tool names are paired.
3
+ *
4
+ * Two independent tables used to encode this: a Claude->pi map for resolving
5
+ * `allowed-tools` entries, and a pi->Claude map for hook payloads and matchers.
6
+ * They disagreed. The second one listed six tools, so a hook matcher written in
7
+ * Claude's vocabulary never fired for `web_fetch`, `subagent`, `question`, `todo`,
8
+ * `slash_command`, `web_search` or `plan_mode_complete`, even though the first table
9
+ * had known those pairings all along. Both directions now derive from this list, so
10
+ * a tool cannot be nameable in one direction and invisible in the other.
11
+ *
12
+ * Canonical names are the tools reference's own spellings. `claude: null` marks a pi
13
+ * tool the reference has no counterpart for: it stays untranslated in payloads, and
14
+ * no Claude name resolves to it beyond its own spelling.
15
+ *
16
+ * Input SHAPES are a separate question and deliberately not driven from here. The
17
+ * hooks reference documents per-tool input tables only for the file and shell tools,
18
+ * so those are the only ones claude-tools.ts translates; for the rest there is no
19
+ * documented Claude shape to conform to and the pi input is passed through.
20
+ */
21
+
22
+ interface ToolNamePair {
23
+ /** Canonical Claude tool name, or null when the reference has no such tool. */
24
+ claude: string | null
25
+ /** pi's registered tool name. */
26
+ pi: string
27
+ /** Further Claude spellings accepted when reading a grant: renamed tools and
28
+ * secondary tools that share one pi implementation. Never used for payloads. */
29
+ aliases?: string[]
30
+ }
31
+
32
+ const TOOL_NAMES: ToolNamePair[] = [
33
+ { claude: 'Read', pi: 'read' },
34
+ { claude: 'Write', pi: 'write' },
35
+ { claude: 'Edit', pi: 'edit' },
36
+ { claude: 'Bash', pi: 'bash' },
37
+ { claude: 'Grep', pi: 'grep' },
38
+ { claude: 'Glob', pi: 'find' },
39
+ { claude: 'WebFetch', pi: 'web_fetch' },
40
+ { claude: 'WebSearch', pi: 'web_search' },
41
+ // `Task` was renamed `Agent`; both spellings resolve, the payload reports `Agent`.
42
+ { claude: 'Agent', pi: 'subagent', aliases: ['Task'] },
43
+ { claude: 'AskUserQuestion', pi: 'question' },
44
+ { claude: 'ExitPlanMode', pi: 'plan_mode_complete' },
45
+ // Custom commands were folded into skills and the reference no longer lists a
46
+ // SlashCommand tool, so `Skill` is canonical and `SlashCommand` is the legacy name.
47
+ { claude: 'Skill', pi: 'slash_command', aliases: ['SlashCommand'] },
48
+ // pi's single todo tool serves Claude's whole todo/task-list family. Claude now
49
+ // prefers TaskCreate/TaskGet/TaskList/TaskUpdate over TodoWrite/TodoRead; every
50
+ // spelling resolves so an allowed-tools entry naming the current tools still grants
51
+ // something, and TodoWrite stays the name a payload reports.
52
+ { claude: 'TodoWrite', pi: 'todo', aliases: ['TodoRead', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate'] },
53
+ // pi tools the tools reference has no entry for.
54
+ { claude: null, pi: 'ls', aliases: ['LS'] },
55
+ ]
56
+
57
+ const fold = (name: string): string => name.trim().toLowerCase()
58
+
59
+ /** Every Claude spelling -> pi tool name, for reading a grant list. Includes each
60
+ * pi name itself so a list written in pi's own vocabulary still resolves. */
61
+ export const CLAUDE_TOOL_MAP: Record<string, string> = Object.fromEntries(TOOL_NAMES.flatMap(({ claude, pi, aliases }) => [...(claude === null ? [] : [[fold(claude), pi]]), ...(aliases ?? []).map((alias) => [fold(alias), pi]), [fold(pi), pi]]))
62
+
63
+ const PI_TO_CLAUDE: Record<string, string> = Object.fromEntries(TOOL_NAMES.filter((pair): pair is ToolNamePair & { claude: string } => pair.claude !== null).map(({ claude, pi }) => [pi, claude]))
64
+
65
+ /** pi built-in -> canonical Claude tool name for hook payloads and matchers, or
66
+ * undefined for a pi tool the reference has no counterpart for. MCP tools ride the
67
+ * alias bus instead and never reach here. */
68
+ export function claudeToolName(piName: string): string | undefined {
69
+ return PI_TO_CLAUDE[piName]
70
+ }