pi-code 1.0.61 → 1.0.62

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()
@@ -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
  }
@@ -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 })
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,20 @@ 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
+ }
60
+
61
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
48
62
 
49
63
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
50
64
  const MAX_TIMEOUT_S = 2_147_483
@@ -104,6 +118,17 @@ function killTree(child: ChildProcess): void {
104
118
  child.kill('SIGKILL')
105
119
  }
106
120
 
121
+ /** Create the plugin data directory the moment its path is handed to a child. Claude
122
+ * describes it as "created on first reference"; a best-effort mkdir, since a hook whose
123
+ * data dir cannot be created should still run. */
124
+ function ensureDir(dir: string): void {
125
+ try {
126
+ fs.mkdirSync(dir, { recursive: true })
127
+ } catch {
128
+ // The hook runs anyway; a script that needs the directory reports its own failure.
129
+ }
130
+ }
131
+
107
132
  /** The shell invocation for a shell-form command, or undefined when this machine has no
108
133
  * shell for it (Windows with neither Git Bash nor PowerShell). */
109
134
  function shellInvocation(command: string, shell: string | undefined): { file: string; spawnArgs: string[] } | undefined {
@@ -111,7 +136,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
111
136
  return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
112
137
  }
113
138
 
114
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild, shell) =>
139
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin } = {}) =>
115
140
  new Promise((resolve) => {
116
141
  // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
117
142
  // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
@@ -125,6 +150,15 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
125
150
  // detection cannot see the captured terminal.
126
151
  const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
127
152
  if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
153
+ // Claude: "All three are exported as environment variables to hook processes and to
154
+ // MCP and LSP server subprocesses", so a plugin script can read them rather than
155
+ // depend on inline substitution. The data directory is "created on first reference",
156
+ // and exporting the path is that reference: a script should not have to mkdir it.
157
+ if (plugin) {
158
+ env.CLAUDE_PLUGIN_ROOT = plugin.root
159
+ env.CLAUDE_PLUGIN_DATA = plugin.dataDir
160
+ ensureDir(plugin.dataDir)
161
+ }
128
162
  if (process.stdout.columns) env.COLUMNS = String(process.stdout.columns)
129
163
  if (process.stdout.rows) env.LINES = String(process.stdout.rows)
130
164
  // 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
+ }