pi-code 0.8.0 → 1.0.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/README.md CHANGED
@@ -36,20 +36,20 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
36
36
  | Feature | Reads / provides | Extension |
37
37
  |---|---|---|
38
38
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
39
- | Custom slash commands | `.claude/commands/*.md` pi prompt templates | `commands.ts` |
40
- | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
39
+ | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`model`/`argument-hint` frontmatter; project commands gated on approval | `commands.ts` |
40
+ | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
41
  | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
42
42
  | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
43
43
  | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
44
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
44
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed` | `mcp.ts` |
45
45
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
- | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; background runs | `subagent/` |
46
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; `skills` preload; background runs with cancel and resume | `subagent/` |
47
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
48
48
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
49
49
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
50
  | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
51
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
- | AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
52
+ | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header`, single- or `multiSelect` options, plus free-text | `question.ts` |
53
53
  | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
54
54
  | Notifications | vendored example | `notify.ts` |
55
55
 
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Claude Commands Extension
3
3
  *
4
- * Bridges Claude Code's custom slash commands into pi. On resources_discover
5
- * it hands pi the existing `.claude/commands` directories (user then project)
6
- * as prompt-template paths, so `/name` invokes `.claude/commands/name.md` the
7
- * same way pi loads its own `.pi/prompts`. pi's `$ARGUMENTS` / `$1` / `${1:-x}`
8
- * substitution overlaps Claude Code's, so most command files work unchanged.
4
+ * Registers Claude Code's custom slash commands with pi directly, rather than
5
+ * handing `.claude/commands` to pi's prompt-template loader. Owning registration
6
+ * is what makes the rest of Claude's command contract reachable: namespaced
7
+ * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
+ * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
9
+ * `allowed-tools` / `model` / `argument-hint` / `disable-model-invocation`
10
+ * frontmatter.
9
11
  *
10
- * Not bridged (pi's prompt engine ignores them): `!` bash execution, `@` file
11
- * refs, `allowed-tools`/`model` frontmatter, and namespaced subdirectories
12
- * (discovery is non-recursive).
12
+ * A project command body is repository-controlled text that can now run shell
13
+ * commands and read files, so project commands load only once the project is
14
+ * approved. That closes the "skills / commands are not trust-gated" limitation
15
+ * for commands; skills remain pi-loader territory.
13
16
  *
14
17
  * Docs: https://code.claude.com/docs/en/slash-commands.md
15
18
  */
@@ -17,7 +20,13 @@
17
20
  import * as fs from 'node:fs'
18
21
  import * as os from 'node:os'
19
22
  import * as path from 'node:path'
20
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
+ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
24
+
25
+ import { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, substituteArgs } from './internal/command-file.js'
26
+ import { isProjectApproved } from './internal/project-approval.js'
27
+
28
+ /** Wall-clock budget for one `` !`cmd` `` span; a hung command must not wedge a turn. */
29
+ const BASH_TIMEOUT_MS = 30_000
21
30
 
22
31
  function isDirectory(target: string): boolean {
23
32
  try {
@@ -27,9 +36,11 @@ function isDirectory(target: string): boolean {
27
36
  }
28
37
  }
29
38
 
30
- /** Existing `.claude/commands` directories, user first then project. */
31
- export function commandDirs(cwd: string, home: string): string[] {
32
- const candidates = [path.join(home, '.claude', 'commands'), path.join(cwd, '.claude', 'commands')]
39
+ /** Existing `.claude/commands` directories, user first then project. The project
40
+ * directory is included only for approved projects. */
41
+ export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
42
+ const candidates = [path.join(home, '.claude', 'commands')]
43
+ if (trusted) candidates.push(path.join(cwd, '.claude', 'commands'))
33
44
  const dirs: string[] = []
34
45
  for (const dir of candidates) {
35
46
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -37,9 +48,68 @@ export function commandDirs(cwd: string, home: string): string[] {
37
48
  return dirs
38
49
  }
39
50
 
51
+ /** All commands across the given directories, later directories winning by name. */
52
+ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
53
+ const byName = new Map<string, DiscoveredCommand>()
54
+ for (const dir of dirs) {
55
+ for (const found of discoverCommandFiles(dir)) byName.set(found.name, found)
56
+ }
57
+ return [...byName.values()]
58
+ }
59
+
40
60
  export default function commandsExtension(pi: ExtensionAPI) {
41
- pi.on('resources_discover', async (_event, ctx) => {
42
- const promptPaths = commandDirs(ctx.cwd, os.homedir())
43
- return promptPaths.length > 0 ? { promptPaths } : undefined
61
+ const registered = new Set<string>()
62
+
63
+ async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
64
+ const withArgs = substituteArgs(parsed.body, args)
65
+ const expanded = await expandDynamicContent(withArgs, ctx.cwd, async (shell) => {
66
+ // Hooks get CLAUDE_PROJECT_DIR, and a command's bash span is the same kind of
67
+ // project-scoped script. pi.exec takes no env, so it is exported in the script.
68
+ const projectDir = ctx.cwd.replaceAll("'", String.raw`'\''`)
69
+ const script = `export CLAUDE_PROJECT_DIR='${projectDir}'; ${shell}`
70
+ const result = await pi.exec('/bin/sh', ['-c', script], { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
71
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code }
72
+ })
73
+
74
+ // allowed-tools restricts the turn the command drives, then the previous set is
75
+ // restored: the restriction belongs to the command, not to the rest of the session.
76
+ const saved = parsed.allowedTools ? pi.getActiveTools() : undefined
77
+ if (parsed.allowedTools && saved) {
78
+ pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
79
+ }
80
+ try {
81
+ pi.sendUserMessage(expanded)
82
+ } finally {
83
+ if (saved) pi.setActiveTools(saved)
84
+ }
85
+ }
86
+
87
+ pi.on('session_start', async (_event, ctx) => {
88
+ const trusted = await isProjectApproved(ctx)
89
+ for (const command of collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted))) {
90
+ // pi has no unregister, so a command already registered this process keeps its
91
+ // original file binding; re-registering would only add a numbered duplicate.
92
+ if (registered.has(command.name)) continue
93
+ let parsed: ParsedCommand
94
+ try {
95
+ parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
96
+ } catch {
97
+ continue // an unreadable command file must not take down session start
98
+ }
99
+ registered.add(command.name)
100
+ pi.registerCommand(command.name, {
101
+ description: parsed.argumentHint ? `${parsed.description} ${parsed.argumentHint}` : parsed.description,
102
+ handler: async (args, commandCtx) => {
103
+ // Re-read on invocation so an edited command file takes effect without a reload.
104
+ let current = parsed
105
+ try {
106
+ current = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
107
+ } catch {
108
+ // fall back to what was parsed at registration
109
+ }
110
+ await runCommand(current, args, commandCtx)
111
+ },
112
+ })
113
+ }
44
114
  })
45
115
  }
@@ -34,6 +34,15 @@ interface Checkpoint {
34
34
  * for the life of the machine. */
35
35
  export const CHECKPOINT_RETENTION_DAYS = 30
36
36
 
37
+ /** Claude keeps the 100 most recent checkpoints per session. Older ones drop off the
38
+ * rewind list; their commits stay in the shadow repo until the retention sweep. */
39
+ export const MAX_CHECKPOINTS_PER_SESSION = 100
40
+
41
+ /** The newest entries, up to the per-session cap, oldest first. */
42
+ export function capCheckpoints<T>(all: T[]): T[] {
43
+ return all.length <= MAX_CHECKPOINTS_PER_SESSION ? all : all.slice(all.length - MAX_CHECKPOINTS_PER_SESSION)
44
+ }
45
+
37
46
  /** Remove shadow repos untouched for longer than the retention window. The live
38
47
  * session's repo is always kept, whatever its age: a long session's directory mtime
39
48
  * can predate the window. Failures are ignored; this is housekeeping, not a gate. */
@@ -209,6 +218,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
209
218
 
210
219
  const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
211
220
  checkpoints.set(checkpoint.entryId, checkpoint)
221
+ // Bound the rewind list the way Claude does, dropping the oldest first.
222
+ for (const stale of [...checkpoints.keys()].slice(0, Math.max(0, checkpoints.size - MAX_CHECKPOINTS_PER_SESSION))) {
223
+ checkpoints.delete(stale)
224
+ }
212
225
  pi.appendEntry(CUSTOM_TYPE, checkpoint)
213
226
  })
214
227
 
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Parsing and discovery for Claude Code slash-command files.
3
+ *
4
+ * pi's own prompt-template loader reads only `description` and `argument-hint`
5
+ * from one flat directory, so the rest of Claude's command contract (namespaced
6
+ * subdirectories, `allowed-tools`, `model`, `!` bash blocks, `@file` refs) lives
7
+ * here and is applied by commands.ts when it registers each command itself.
8
+ *
9
+ * Docs: https://code.claude.com/docs/en/slash-commands.md
10
+ */
11
+
12
+ import * as fs from 'node:fs'
13
+ import * as path from 'node:path'
14
+
15
+ export interface ParsedCommand {
16
+ description: string
17
+ argumentHint?: string
18
+ allowedTools?: string[]
19
+ model?: string
20
+ disableModelInvocation: boolean
21
+ body: string
22
+ }
23
+
24
+ export interface DiscoveredCommand {
25
+ /** Claude's namespaced name: a nested file is `dir:name`. */
26
+ name: string
27
+ filePath: string
28
+ }
29
+
30
+ /** Claude tool names are PascalCase; pi's are lowercase. */
31
+ function normalizeToolName(name: string): string {
32
+ return name.trim().toLowerCase()
33
+ }
34
+
35
+ function field(frontmatter: string, key: string): string {
36
+ const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
37
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
38
+ }
39
+
40
+ export function parseCommandFile(content: string): ParsedCommand {
41
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
42
+ const frontmatter = match ? match[1] : ''
43
+ const body = (match ? content.slice(match[0].length) : content).trim()
44
+ const tools = field(frontmatter, 'allowed-tools')
45
+ const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
46
+ return {
47
+ description: field(frontmatter, 'description') || firstLine.slice(0, 60),
48
+ argumentHint: field(frontmatter, 'argument-hint') || undefined,
49
+ allowedTools: tools ? tools.split(',').map(normalizeToolName).filter(Boolean) : undefined,
50
+ model: field(frontmatter, 'model') || undefined,
51
+ disableModelInvocation: field(frontmatter, 'disable-model-invocation') === 'true',
52
+ body,
53
+ }
54
+ }
55
+
56
+ /** Split a raw argument string, keeping quoted runs together. */
57
+ export function splitArgs(args: string): string[] {
58
+ const out: string[] = []
59
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
60
+ let match = pattern.exec(args)
61
+ while (match !== null) {
62
+ out.push(match[1] ?? match[2] ?? match[3])
63
+ match = pattern.exec(args)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`. An
69
+ * unfilled positional becomes empty rather than leaking its literal token. */
70
+ export function substituteArgs(body: string, args: string): string {
71
+ const parts = splitArgs(args)
72
+ return body
73
+ .replaceAll(/\$\{(\d+):-([^}]*)\}/g, (_m, index: string, fallback: string) => parts[Number(index) - 1] ?? fallback)
74
+ .replaceAll(/\$\{ARGUMENTS:-([^}]*)\}/g, (_m, fallback: string) => (args.trim() ? args.trim() : fallback))
75
+ .replaceAll(/\$ARGUMENTS\b/g, args.trim())
76
+ .replaceAll('$@', args.trim())
77
+ .replaceAll(/\$(\d+)/g, (_m, index: string) => parts[Number(index) - 1] ?? '')
78
+ }
79
+
80
+ /** `a/b/c.md` becomes Claude's `a:b:c`. */
81
+ export function commandNameFor(relativePath: string): string {
82
+ return relativePath.replace(/\.md$/, '').split(path.sep).join(':')
83
+ }
84
+
85
+ /** Every `*.md` under a commands directory, including nested ones. */
86
+ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
87
+ const found: DiscoveredCommand[] = []
88
+ const walk = (dir: string, prefix: string): void => {
89
+ let entries: fs.Dirent[]
90
+ try {
91
+ entries = fs.readdirSync(dir, { withFileTypes: true })
92
+ } catch {
93
+ return
94
+ }
95
+ for (const entry of entries) {
96
+ const full = path.join(dir, entry.name)
97
+ if (entry.isDirectory()) walk(full, path.join(prefix, entry.name))
98
+ else if (entry.name.endsWith('.md')) found.push({ name: commandNameFor(path.join(prefix, entry.name)), filePath: full })
99
+ }
100
+ }
101
+ walk(root, '')
102
+ return found
103
+ }
104
+
105
+ export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
106
+
107
+ /** Spans of a body that are inside a fenced code block, where Claude's dynamic
108
+ * syntax is literal text rather than an instruction. */
109
+ function fencedRanges(body: string): Array<[number, number]> {
110
+ const ranges: Array<[number, number]> = []
111
+ const fence = /^(```|~~~)[^\n]*$/gm
112
+ let open: number | undefined
113
+ let match = fence.exec(body)
114
+ while (match !== null) {
115
+ if (open === undefined) open = match.index
116
+ else {
117
+ ranges.push([open, match.index + match[0].length])
118
+ open = undefined
119
+ }
120
+ match = fence.exec(body)
121
+ }
122
+ if (open !== undefined) ranges.push([open, body.length])
123
+ return ranges
124
+ }
125
+
126
+ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
127
+
128
+ /** Read a `@path` reference, confined to the working directory. Returns undefined
129
+ * when the path escapes it or cannot be read, so the reference stays literal. */
130
+ function readReference(cwd: string, reference: string): string | undefined {
131
+ const resolved = path.resolve(cwd, reference)
132
+ const root = path.resolve(cwd)
133
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return undefined
134
+ try {
135
+ if (!fs.statSync(resolved).isFile()) return undefined
136
+ return fs.readFileSync(resolved, 'utf-8')
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
142
+ /** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
143
+ * its output, `@path` inlines a file. Both are skipped inside fenced code blocks. */
144
+ export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
145
+ const fenced = fencedRanges(body)
146
+
147
+ const commands: Array<{ span: string; command: string; index: number }> = []
148
+ const bashPattern = /!`([^`]+)`/g
149
+ let bashMatch = bashPattern.exec(body)
150
+ while (bashMatch !== null) {
151
+ if (!inRanges(fenced, bashMatch.index)) commands.push({ span: bashMatch[0], command: bashMatch[1], index: bashMatch.index })
152
+ bashMatch = bashPattern.exec(body)
153
+ }
154
+
155
+ let expanded = body
156
+ for (const entry of commands) {
157
+ const result = await exec(entry.command)
158
+ const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
159
+ expanded = expanded.replace(entry.span, output)
160
+ }
161
+
162
+ // Ranges are recomputed: command output can change offsets.
163
+ const fencedAfter = fencedRanges(expanded)
164
+ return expanded.replaceAll(/(^|\s)@(\S+)/g, (whole, lead: string, reference: string, offset: number) => {
165
+ if (inRanges(fencedAfter, offset)) return whole
166
+ const content = readReference(cwd, reference)
167
+ if (content === undefined) return whole
168
+ return `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
169
+ })
170
+ }
package/extensions/mcp.ts CHANGED
@@ -28,6 +28,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
28
28
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
29
29
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
30
30
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
31
+ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
31
32
  import { Type } from 'typebox'
32
33
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
33
34
  import { capForContext } from './internal/output-guard.js'
@@ -317,8 +318,14 @@ async function connectWithTimeout(client: Client, transport: Parameters<Client['
317
318
  }
318
319
  }
319
320
 
320
- async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
321
- const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
321
+ export interface McpToolInfo {
322
+ name: string
323
+ description?: string
324
+ inputSchema?: unknown
325
+ }
326
+
327
+ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
328
+ const tools: McpToolInfo[] = []
322
329
  let cursor: string | undefined
323
330
  do {
324
331
  const page = await client.listTools({ cursor })
@@ -331,10 +338,73 @@ async function listAllTools(client: Client): Promise<Array<{ name: string; descr
331
338
  export default async function mcpExtension(pi: ExtensionAPI) {
332
339
  const clients = new Map<string, Client>()
333
340
  const status = new Map<string, { state: string; tools: number }>()
334
- const registered = new Set<string>()
341
+ // pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
342
+ const registered = new Map<string, string>()
335
343
  // Original server/tool names per registered pi name, for Claude-style hook matchers.
336
344
  const aliases: McpToolAlias[] = []
337
345
 
346
+ /** Register every not-yet-registered tool of a server; returns how many were added. */
347
+ function registerTools(name: string, config: ServerConfig, client: Client, tools: McpToolInfo[]): number {
348
+ let count = 0
349
+ for (const tool of tools) {
350
+ const toolName = formatToolName(name, tool.name)
351
+ const owner = registered.get(toolName)
352
+ if (owner === name) continue // already registered for this server: a refresh re-listing it
353
+ if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
354
+ console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
355
+ continue
356
+ }
357
+ registered.set(toolName, name)
358
+ aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
359
+ count++
360
+ pi.registerTool({
361
+ name: toolName,
362
+ label: `${name}: ${tool.name}`,
363
+ description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
364
+ parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
365
+ async execute(_id, params) {
366
+ // Pass the timeout to the SDK too: its own default request timeout is 60s and
367
+ // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
368
+ // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
369
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
370
+ const budget = declared ?? callTimeoutMs()
371
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
372
+ const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
373
+ const details: { error?: string } = {}
374
+ if (result.isError) {
375
+ details.error = 'tool_error'
376
+ const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
377
+ content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
378
+ }
379
+ return { content, details }
380
+ },
381
+ })
382
+ }
383
+ return count
384
+ }
385
+
386
+ /** Claude refreshes tools on a server's list_changed notification. pi has no
387
+ * unregister, so a withdrawn tool keeps its registration and surfaces the server's
388
+ * own error when called; a newly announced one is registered without a restart. */
389
+ function subscribeToToolChanges(name: string, config: ServerConfig, client: Client): void {
390
+ try {
391
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
392
+ try {
393
+ const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
394
+ const added = registerTools(name, config, client, refreshed)
395
+ if (added === 0) return
396
+ const current = status.get(name)
397
+ status.set(name, { state: current?.state ?? 'connected', tools: (current?.tools ?? 0) + added })
398
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
399
+ } catch (error) {
400
+ console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
401
+ }
402
+ })
403
+ } catch {
404
+ // a transport or client without notification support simply never refreshes
405
+ }
406
+ }
407
+
338
408
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
339
409
  for (const [name, config] of Object.entries(servers)) {
340
410
  // A later scope must not take the name of a server that already connected: it
@@ -349,39 +419,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
349
419
  const client = await connect(name, config)
350
420
  clients.set(name, client)
351
421
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
352
- let count = 0
353
- for (const tool of tools) {
354
- const toolName = formatToolName(name, tool.name)
355
- if (RESERVED_NAMES.has(toolName) || registered.has(toolName)) {
356
- console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
357
- continue
358
- }
359
- registered.add(toolName)
360
- aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
361
- count++
362
- pi.registerTool({
363
- name: toolName,
364
- label: `${name}: ${tool.name}`,
365
- description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
366
- parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
367
- async execute(_id, params) {
368
- // Pass the timeout to the SDK too: its own default request timeout is 60s and
369
- // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
370
- // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
371
- const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
372
- const budget = declared ?? callTimeoutMs()
373
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
374
- const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
375
- const details: { error?: string } = {}
376
- if (result.isError) {
377
- details.error = 'tool_error'
378
- const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
379
- content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
380
- }
381
- return { content, details }
382
- },
383
- })
384
- }
422
+ const count = registerTools(name, config, client, tools)
423
+ subscribeToToolChanges(name, config, client)
385
424
  status.set(name, { state: 'connected', tools: count })
386
425
  } catch (error) {
387
426
  status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
@@ -63,6 +63,40 @@ export function migrateLegacyStore(cwd: string): void {
63
63
  }
64
64
  }
65
65
 
66
+ /** Whether adding this memory would push the index past what a session can load.
67
+ * Claude reports an explicit error instead of silently writing a memory that will
68
+ * never be seen; replacing an existing entry is not growth. */
69
+ export function indexWouldOverflow(index: string, name: string, description: string): boolean {
70
+ // Editing an entry that already exists is always allowed: it adds no entry, and
71
+ // refusing it would strand a user whose index is already at the bound with no way
72
+ // to revise their way back under it. An over-long description is bounded anyway,
73
+ // since the injected index is capped at read time.
74
+ const isUpdate = index.split('\n').some((entry) => entry.startsWith(entryPrefix(name)))
75
+ if (isUpdate) return false
76
+ const next = upsertIndexLine(index, name, description)
77
+ return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
78
+ }
79
+
80
+ /** Write a memory and its index line, or say why it cannot be written. */
81
+ export function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
82
+ if (!name || !description || !content) {
83
+ return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
84
+ }
85
+ const index = readIndex(dir)
86
+ // Claude reports an explicit error rather than writing a memory the next session
87
+ // would never load, and says what to do about it.
88
+ if (indexWouldOverflow(index, name, description)) {
89
+ return {
90
+ content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
91
+ details: {},
92
+ }
93
+ }
94
+ fs.mkdirSync(dir, { recursive: true })
95
+ fs.writeFileSync(path.join(dir, `${name}.md`), content)
96
+ fs.writeFileSync(indexPath, upsertIndexLine(index, name, description))
97
+ return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
98
+ }
99
+
66
100
  /** The index as injected into the prompt, bounded like Claude's startup load. */
67
101
  export function capIndexForPrompt(index: string): string {
68
102
  const withinLines = index.split('\n').slice(0, INDEX_MAX_LINES)
@@ -151,13 +185,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
151
185
  const indexPath = path.join(dir, INDEX_FILE)
152
186
 
153
187
  if (params.action === 'save') {
154
- if (!name || !params.description || !params.content) {
155
- return { content: [{ type: 'text' as const, text: 'save requires name, description, and content.' }], details: {} }
156
- }
157
- fs.mkdirSync(dir, { recursive: true })
158
- fs.writeFileSync(path.join(dir, `${name}.md`), params.content)
159
- fs.writeFileSync(indexPath, upsertIndexLine(readIndex(dir), name, params.description))
160
- return { content: [{ type: 'text' as const, text: `Saved memory ${name}.` }], details: {} }
188
+ return saveMemory(dir, indexPath, name, params.description, params.content)
161
189
  }
162
190
 
163
191
  if (params.action === 'read') {