pi-code 0.4.1 → 0.5.0

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.
@@ -22,9 +22,11 @@
22
22
  *
23
23
  * Config is merged from ~/.claude/settings.json (always) plus the project's
24
24
  * .claude/settings.json and settings.local.json (only when the project is
25
- * trusted, since hooks execute arbitrary shell). Claude tool matchers are
26
- * PascalCase (`Bash`); pi tool names are lowercase (`bash`), so matchers are
27
- * applied case-insensitively.
25
+ * trusted, since hooks execute arbitrary shell). Matchers follow Claude's rule:
26
+ * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
27
+ * anything with other regex characters is an unanchored regex. Claude matchers
28
+ * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
29
+ * is case-insensitive and folds `-` to `_`.
28
30
  *
29
31
  * Docs: https://code.claude.com/docs/en/hooks.md
30
32
  */
@@ -35,6 +37,7 @@ import * as os from 'node:os'
35
37
  import * as path from 'node:path'
36
38
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
37
39
 
40
+ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
38
41
  import { isProjectApproved } from './internal/project-approval.js'
39
42
 
40
43
  const DEFAULT_TIMEOUT_S = 60
@@ -86,12 +89,34 @@ export function loadHooks(files: string[]): HooksConfig {
86
89
  return config
87
90
  }
88
91
 
89
- function matcherApplies(matcher: string | undefined, name: string): boolean {
92
+ /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
93
+ * is a list of exact names; anything else is an unanchored regex. */
94
+ const EXACT_MATCHER = /^[\w\- ,|]*$/
95
+
96
+ /** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
97
+ * pi names are lowercase with underscores, so comparison folds both. */
98
+ function foldName(name: string): string {
99
+ return name.toLowerCase().replaceAll('-', '_')
100
+ }
101
+
102
+ function exactListApplies(matcher: string, names: readonly string[]): boolean {
103
+ const tokens = new Set(
104
+ matcher
105
+ .split(/[|,]/)
106
+ .map((token) => foldName(token.trim()))
107
+ .filter(Boolean),
108
+ )
109
+ return names.some((name) => tokens.has(foldName(name)))
110
+ }
111
+
112
+ function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
90
113
  if (!matcher || matcher === '*') return true
114
+ if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
91
115
  try {
92
- return new RegExp(`^(?:${matcher})$`, 'i').test(name)
116
+ const regex = new RegExp(matcher, 'i')
117
+ return names.some((name) => regex.test(name))
93
118
  } catch {
94
- return matcher.toLowerCase() === name.toLowerCase()
119
+ return exactListApplies(matcher, names)
95
120
  }
96
121
  }
97
122
 
@@ -101,11 +126,20 @@ function isRunnableHook(hook: HookCommand): boolean {
101
126
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
102
127
  }
103
128
 
104
- /** Command specs whose matcher applies to the given tool/source name. */
105
- export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
129
+ /** Command specs whose matcher applies to any of the given tool/source names.
130
+ * Multiple candidates let one event offer both the pi name and its Claude alias. */
131
+ export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
132
+ const candidates = typeof names === 'string' ? [names] : names
106
133
  const result: HookCommand[] = []
134
+ const seen = new Set<string>()
107
135
  for (const entry of matchers ?? []) {
108
- if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []).filter(isRunnableHook))
136
+ if (!matcherApplies(entry.matcher, candidates)) continue
137
+ for (const hook of (entry.hooks ?? []).filter(isRunnableHook)) {
138
+ // Claude runs a handler defined in more than one settings file once.
139
+ if (seen.has(hook.command)) continue
140
+ seen.add(hook.command)
141
+ result.push(hook)
142
+ }
109
143
  }
110
144
  return result
111
145
  }
@@ -208,10 +242,13 @@ function timeoutMs(command: HookCommand): number {
208
242
  return seconds * 1000
209
243
  }
210
244
 
211
- /** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
212
- export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
213
- for (const command of matchingCommands(config.PreToolUse, toolName)) {
214
- const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
245
+ /** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
246
+ * matcher sees both the pi name and the Claude alias, and the payload reports the alias,
247
+ * which is the name a Claude-written hook script expects in tool_name. */
248
+ export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string): Promise<HookDecision> {
249
+ const names = claudeName ? [toolName, claudeName] : [toolName]
250
+ for (const command of matchingCommands(config.PreToolUse, names)) {
251
+ const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
215
252
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
216
253
  // would otherwise read as a clean allow. Fail closed instead.
217
254
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
@@ -257,6 +294,19 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
257
294
  /** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
258
295
  const MAX_PENDING_INPUTS = 100
259
296
 
297
+ /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
298
+ * offered both spellings so existing configs keep firing either way, and the payload
299
+ * reports the Claude value, which is what a Claude-written hook script parses. */
300
+ const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
301
+ const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
302
+ const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
303
+
304
+ /** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
305
+ function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
306
+ const value = map[raw] ?? raw
307
+ return { names: value === raw ? [raw] : [raw, value], value }
308
+ }
309
+
260
310
  export default function hooksExtension(pi: ExtensionAPI) {
261
311
  let config: HooksConfig = {}
262
312
  let projectDir = ''
@@ -264,15 +314,24 @@ export default function hooksExtension(pi: ExtensionAPI) {
264
314
  // contract does, so remember it from tool_call keyed by the call id.
265
315
  const pendingInputs = new Map<string, unknown>()
266
316
  const runner: HookRunner = (command, payload, ms) => runHookCommand(command, payload, ms, projectDir)
317
+ // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
318
+ // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
319
+ const mcpAliases = new Map<string, string>()
320
+ pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
321
+ if (!isMcpToolAliases(data)) return
322
+ mcpAliases.clear()
323
+ for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
324
+ })
267
325
 
268
326
  pi.on('session_start', async (event, ctx) => {
269
327
  const trusted = await isProjectApproved(ctx)
270
328
  projectDir = ctx.cwd
271
329
  config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
272
- // Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
273
- // "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
274
- if (event.reason === 'reload' || event.reason === 'fork') return
275
- await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runner)
330
+ // "reload" re-fires in-process with the same conversation and would double-run hooks;
331
+ // a fork is a genuine session begin, which Claude reports as source "fork".
332
+ if (event.reason === 'reload') return
333
+ const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
334
+ await runNotifyHooks(matchingCommands(config.SessionStart, source.names), { hook_event_name: 'SessionStart', source: source.value }, runner)
276
335
  })
277
336
 
278
337
  pi.on('tool_call', async (event) => {
@@ -281,7 +340,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
281
340
  const oldest = pendingInputs.keys().next().value
282
341
  if (oldest !== undefined) pendingInputs.delete(oldest)
283
342
  }
284
- const decision = await runPreToolUse(config, event.toolName, event.input, runner)
343
+ const decision = await runPreToolUse(config, event.toolName, event.input, runner, mcpAliases.get(event.toolName))
285
344
  if (!decision.block) return undefined
286
345
  // pi still emits tool_execution_end (isError) for a blocked call, which also
287
346
  // cleans up; deleting here just avoids relying on that host detail.
@@ -293,7 +352,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
293
352
  const toolInput = pendingInputs.get(event.toolCallId)
294
353
  pendingInputs.delete(event.toolCallId)
295
354
  if (event.isError) return
296
- await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
355
+ const alias = mcpAliases.get(event.toolName)
356
+ const names = alias ? [event.toolName, alias] : [event.toolName]
357
+ await runNotifyHooks(matchingCommands(config.PostToolUse, names), { hook_event_name: 'PostToolUse', tool_name: alias ?? event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
297
358
  })
298
359
 
299
360
  pi.on('input', async (event, ctx) => {
@@ -319,10 +380,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
319
380
  })
320
381
 
321
382
  pi.on('session_before_compact', async (event) => {
322
- await runNotifyHooks(matchingCommands(config.PreCompact, event.reason), { hook_event_name: 'PreCompact', trigger: event.reason }, runner)
383
+ const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
384
+ await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, runner)
323
385
  })
324
386
 
325
387
  pi.on('session_shutdown', async (event) => {
326
- await runNotifyHooks(matchingCommands(config.SessionEnd, event.reason), { hook_event_name: 'SessionEnd', reason: event.reason }, runner)
388
+ const reason = claudeSpelling(SESSION_END_REASON, event.reason)
389
+ await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, runner)
327
390
  })
328
391
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Channel and payload for the MCP tool-name registry the mcp extension publishes on
3
+ * pi's shared extension event bus. Claude Code names MCP tools `mcp__<server>__<tool>`
4
+ * (original names, dashes preserved); pi-code registers them as `<server>_<tool>` with
5
+ * dashes folded to underscores. Hook matchers written for Claude need the mapping, and
6
+ * pi loads every extension without a shared module cache, so cross-extension state must
7
+ * ride the bus rather than a module singleton.
8
+ */
9
+
10
+ export const MCP_TOOLS_CHANNEL = 'pi-code:mcp-tools'
11
+
12
+ export interface McpToolAlias {
13
+ /** Tool name as registered in pi, e.g. `github_create_issue`. */
14
+ pi: string
15
+ /** Claude Code's name for the same tool, e.g. `mcp__github__create_issue`. */
16
+ claude: string
17
+ }
18
+
19
+ export function isMcpToolAliases(data: unknown): data is McpToolAlias[] {
20
+ return Array.isArray(data) && data.every((entry) => typeof (entry as McpToolAlias)?.pi === 'string' && typeof (entry as McpToolAlias)?.claude === 'string')
21
+ }
package/extensions/mcp.ts CHANGED
@@ -29,6 +29,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
29
29
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
30
30
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
31
31
  import { Type } from 'typebox'
32
+ import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
32
33
  import { capForContext } from './internal/output-guard.js'
33
34
  import { isProjectApproved } from './internal/project-approval.js'
34
35
 
@@ -261,6 +262,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
261
262
  const clients = new Map<string, Client>()
262
263
  const status = new Map<string, { state: string; tools: number }>()
263
264
  const registered = new Set<string>()
265
+ // Original server/tool names per registered pi name, for Claude-style hook matchers.
266
+ const aliases: McpToolAlias[] = []
264
267
 
265
268
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
266
269
  for (const [name, config] of Object.entries(servers)) {
@@ -283,6 +286,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
283
286
  continue
284
287
  }
285
288
  registered.add(toolName)
289
+ aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
286
290
  count++
287
291
  pi.registerTool({
288
292
  name: toolName,
@@ -328,6 +332,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
328
332
  await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
329
333
  }
330
334
 
335
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
336
+
331
337
  const connected = [...status.values()].filter((s) => s.state === 'connected')
332
338
  const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
333
339
  if (connected.length > 0 || failed.length > 0) {
@@ -18,7 +18,10 @@ import { capForContext } from './internal/output-guard.js'
18
18
  const INDEX_FILE = 'MEMORY.md'
19
19
 
20
20
  export function projectSlug(cwd: string): string {
21
- return cwd.replace(/[/\\]/g, '-').replace(/^-+/, '-')
21
+ return cwd
22
+ .replace(/^([A-Za-z]):(?=[/\\])/, '$1')
23
+ .replace(/[/\\]/g, '-')
24
+ .replace(/^-+/, '-')
22
25
  }
23
26
 
24
27
  export function memoryDir(cwd: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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, and subagents",
5
5
  "keywords": [
6
6
  "pi",
@@ -61,10 +61,10 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@biomejs/biome": "^2.5.4",
64
- "@earendil-works/pi-agent-core": "^0.81.1",
65
- "@earendil-works/pi-ai": "^0.81.1",
66
- "@earendil-works/pi-coding-agent": "^0.81.1",
67
- "@earendil-works/pi-tui": "^0.81.1",
64
+ "@earendil-works/pi-agent-core": "^0.84.0",
65
+ "@earendil-works/pi-ai": "^0.84.0",
66
+ "@earendil-works/pi-coding-agent": "^0.84.0",
67
+ "@earendil-works/pi-tui": "^0.84.0",
68
68
  "@types/node": "^26.1.1",
69
69
  "@vitest/coverage-v8": "^4.1.10",
70
70
  "typescript": "^7.0.2",