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 +5 -5
- package/extensions/commands.ts +85 -15
- package/extensions/git-checkpoint.ts +13 -0
- package/extensions/internal/command-file.ts +170 -0
- package/extensions/mcp.ts +75 -36
- package/extensions/memory.ts +35 -7
- package/extensions/question.ts +196 -150
- package/extensions/skills.ts +12 -3
- package/extensions/subagent/README.md +12 -5
- package/extensions/subagent/agents.ts +73 -0
- package/extensions/subagent/background.ts +38 -3
- package/extensions/subagent/index.ts +63 -24
- package/package.json +1 -1
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
|
|
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 |
|
|
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
|
|
package/extensions/commands.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Commands Extension
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* substitution
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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 })
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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') {
|