pi-code 0.4.2 → 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.
- package/extensions/hooks.ts +84 -21
- package/extensions/internal/mcp-alias.ts +21 -0
- package/extensions/mcp.ts +6 -0
- package/extensions/memory.ts +4 -1
- package/package.json +1 -1
package/extensions/hooks.ts
CHANGED
|
@@ -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).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
const regex = new RegExp(matcher, 'i')
|
|
117
|
+
return names.some((name) => regex.test(name))
|
|
93
118
|
} catch {
|
|
94
|
-
return matcher
|
|
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
|
|
105
|
-
|
|
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,
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
if (event.reason === 'reload'
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|