pi-code 0.1.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/LICENSE +22 -0
- package/README.md +51 -0
- package/extensions/claude-rules.ts +136 -0
- package/extensions/commands.ts +45 -0
- package/extensions/context-imports.ts +118 -0
- package/extensions/git-checkpoint.ts +199 -0
- package/extensions/hooks.ts +178 -0
- package/extensions/mcp.ts +268 -0
- package/extensions/memory.ts +127 -0
- package/extensions/notify.ts +52 -0
- package/extensions/output-styles.ts +159 -0
- package/extensions/plan-mode/README.md +65 -0
- package/extensions/plan-mode/index.ts +352 -0
- package/extensions/plan-mode/utils.ts +175 -0
- package/extensions/question.ts +258 -0
- package/extensions/skills.ts +41 -0
- package/extensions/status-line.ts +59 -0
- package/extensions/subagent/README.md +172 -0
- package/extensions/subagent/agents/planner.md +37 -0
- package/extensions/subagent/agents/reviewer.md +35 -0
- package/extensions/subagent/agents/scout.md +50 -0
- package/extensions/subagent/agents/worker.md +24 -0
- package/extensions/subagent/agents.ts +147 -0
- package/extensions/subagent/background.ts +89 -0
- package/extensions/subagent/index.ts +953 -0
- package/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/extensions/subagent/prompts/implement.md +10 -0
- package/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/extensions/todo.ts +504 -0
- package/extensions/web.ts +195 -0
- package/package.json +59 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Hooks Extension
|
|
3
|
+
*
|
|
4
|
+
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
|
+
* a project's existing hooks work under pi:
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool)
|
|
7
|
+
* - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
|
|
8
|
+
* - SessionStart-> pi `session_start` (fire-and-forget)
|
|
9
|
+
*
|
|
10
|
+
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
11
|
+
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
12
|
+
* `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
|
|
13
|
+
* `{"decision": "block"}`).
|
|
14
|
+
*
|
|
15
|
+
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
16
|
+
* .claude/settings.json and settings.local.json (only when the project is
|
|
17
|
+
* trusted, since hooks execute arbitrary shell). Claude tool matchers are
|
|
18
|
+
* PascalCase (`Bash`); pi tool names are lowercase (`bash`), so matchers are
|
|
19
|
+
* applied case-insensitively.
|
|
20
|
+
*
|
|
21
|
+
* Docs: https://code.claude.com/docs/en/hooks.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import * as fs from 'node:fs'
|
|
26
|
+
import * as os from 'node:os'
|
|
27
|
+
import * as path from 'node:path'
|
|
28
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMEOUT_S = 60
|
|
31
|
+
|
|
32
|
+
interface HookCommand {
|
|
33
|
+
type?: string
|
|
34
|
+
command: string
|
|
35
|
+
timeout?: number
|
|
36
|
+
}
|
|
37
|
+
interface HookMatcher {
|
|
38
|
+
matcher?: string
|
|
39
|
+
hooks: HookCommand[]
|
|
40
|
+
}
|
|
41
|
+
export type HooksConfig = Record<string, HookMatcher[]>
|
|
42
|
+
|
|
43
|
+
export interface HookDecision {
|
|
44
|
+
block: boolean
|
|
45
|
+
reason?: string
|
|
46
|
+
}
|
|
47
|
+
export interface HookRunResult {
|
|
48
|
+
code: number
|
|
49
|
+
stdout: string
|
|
50
|
+
stderr: string
|
|
51
|
+
}
|
|
52
|
+
export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
53
|
+
|
|
54
|
+
/** Settings files to read, newest-winning. Project files load only when trusted. */
|
|
55
|
+
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
56
|
+
const files = [path.join(home, '.claude', 'settings.json')]
|
|
57
|
+
if (trusted) files.push(path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json'))
|
|
58
|
+
return files
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadHooks(files: string[]): HooksConfig {
|
|
62
|
+
const config: HooksConfig = {}
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
let parsed: { hooks?: HooksConfig }
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
67
|
+
} catch {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
for (const [event, matchers] of Object.entries(parsed.hooks ?? {})) {
|
|
71
|
+
if (Array.isArray(matchers)) config[event] = [...(config[event] ?? []), ...matchers]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return config
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function matcherApplies(matcher: string | undefined, name: string): boolean {
|
|
78
|
+
if (!matcher || matcher === '*') return true
|
|
79
|
+
try {
|
|
80
|
+
return new RegExp(`^(?:${matcher})$`, 'i').test(name)
|
|
81
|
+
} catch {
|
|
82
|
+
return matcher.toLowerCase() === name.toLowerCase()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Command specs whose matcher applies to the given tool/source name. */
|
|
87
|
+
export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
|
|
88
|
+
const result: HookCommand[] = []
|
|
89
|
+
for (const entry of matchers ?? []) {
|
|
90
|
+
if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []))
|
|
91
|
+
}
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text)
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Map a hook's exit code / output to a block-or-allow decision. */
|
|
104
|
+
export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
|
|
105
|
+
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
106
|
+
const parsed = tryParseJson(stdout)
|
|
107
|
+
const specific = parsed?.hookSpecificOutput
|
|
108
|
+
if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
109
|
+
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
110
|
+
return { block: false }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
const child = spawn('sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
116
|
+
let stdout = ''
|
|
117
|
+
let stderr = ''
|
|
118
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
119
|
+
child.stdout?.on('data', (chunk) => {
|
|
120
|
+
stdout += chunk
|
|
121
|
+
})
|
|
122
|
+
child.stderr?.on('data', (chunk) => {
|
|
123
|
+
stderr += chunk
|
|
124
|
+
})
|
|
125
|
+
child.on('close', (code) => {
|
|
126
|
+
clearTimeout(timer)
|
|
127
|
+
resolve({ code: code ?? 0, stdout, stderr })
|
|
128
|
+
})
|
|
129
|
+
child.on('error', () => {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
resolve({ code: 0, stdout, stderr })
|
|
132
|
+
})
|
|
133
|
+
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
134
|
+
// so ignore EPIPE on this write rather than crashing the host process.
|
|
135
|
+
child.stdin?.on('error', () => {})
|
|
136
|
+
child.stdin?.end(JSON.stringify(payload))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function timeoutMs(command: HookCommand): number {
|
|
140
|
+
return (command.timeout ?? DEFAULT_TIMEOUT_S) * 1000
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
|
|
144
|
+
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
|
|
145
|
+
for (const command of matchingCommands(config.PreToolUse, toolName)) {
|
|
146
|
+
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
|
|
147
|
+
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
148
|
+
if (decision.block) return decision
|
|
149
|
+
}
|
|
150
|
+
return { block: false }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<void> {
|
|
154
|
+
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default function hooksExtension(pi: ExtensionAPI) {
|
|
158
|
+
let config: HooksConfig = {}
|
|
159
|
+
|
|
160
|
+
pi.on('session_start', async (event, ctx) => {
|
|
161
|
+
const trusted = ctx.isProjectTrusted?.() ?? false
|
|
162
|
+
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
163
|
+
// Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
|
|
164
|
+
// "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
|
|
165
|
+
if (event.reason === 'reload' || event.reason === 'fork') return
|
|
166
|
+
await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runHookCommand)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
pi.on('tool_call', async (event) => {
|
|
170
|
+
const decision = await runPreToolUse(config, event.toolName, event.input, runHookCommand)
|
|
171
|
+
return decision.block ? { block: true, reason: decision.reason } : undefined
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
pi.on('tool_execution_end', async (event) => {
|
|
175
|
+
if (event.isError) return
|
|
176
|
+
await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName }, runHookCommand)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Adapter Extension
|
|
3
|
+
*
|
|
4
|
+
* Connects MCP (Model Context Protocol) servers from mcp.json and registers
|
|
5
|
+
* their tools in pi as `<server>_<tool>`. Async factory connects eagerly at
|
|
6
|
+
* startup (per-server timeout, failures skip with a notice); stdio and HTTP
|
|
7
|
+
* (streamable with SSE fallback) transports; /mcp shows status.
|
|
8
|
+
*
|
|
9
|
+
* Reads Claude Code's MCP config too. Merge order (later wins): ~/.claude.json,
|
|
10
|
+
* ~/.pi/agent/mcp.json, .mcp.json, then .pi/mcp.json. User config connects at
|
|
11
|
+
* startup; project config (.mcp.json / .pi/mcp.json) can run arbitrary commands,
|
|
12
|
+
* so it connects only once the project is trusted.
|
|
13
|
+
* Values support ${VAR} environment interpolation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as os from 'node:os'
|
|
18
|
+
import * as path from 'node:path'
|
|
19
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
21
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
|
22
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
23
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
24
|
+
import { Type } from 'typebox'
|
|
25
|
+
|
|
26
|
+
const CONNECT_TIMEOUT_MS = 10_000
|
|
27
|
+
const CALL_TIMEOUT_MS = 120_000
|
|
28
|
+
const MAX_INLINE_RESULT = 50_000
|
|
29
|
+
// pi's built-in tool names must never be shadowed by an MCP tool
|
|
30
|
+
const RESERVED_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'mcp'])
|
|
31
|
+
|
|
32
|
+
export interface StdioServerConfig {
|
|
33
|
+
command: string
|
|
34
|
+
args?: string[]
|
|
35
|
+
env?: Record<string, string>
|
|
36
|
+
cwd?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HttpServerConfig {
|
|
40
|
+
url: string
|
|
41
|
+
headers?: Record<string, string>
|
|
42
|
+
bearerToken?: string
|
|
43
|
+
bearerTokenEnv?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ServerConfig = StdioServerConfig | HttpServerConfig
|
|
47
|
+
|
|
48
|
+
export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
49
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** User-scoped MCP config (the user's own; safe to load without project trust). */
|
|
53
|
+
export function userConfigPaths(home: string): string[] {
|
|
54
|
+
return [path.join(home, '.claude.json'), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Project-scoped MCP config. Loaded only for trusted projects: a server's `command` runs on connect. */
|
|
58
|
+
export function projectConfigPaths(cwd: string): string[] {
|
|
59
|
+
return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All config files, later winning: user first, then project. */
|
|
63
|
+
export function configPaths(cwd: string, home: string): string[] {
|
|
64
|
+
return [...userConfigPaths(home), ...projectConfigPaths(cwd)]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loadConfig(cwd: string): Record<string, ServerConfig> {
|
|
68
|
+
return loadConfigFrom(configPaths(cwd, os.homedir()))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
72
|
+
const servers: Record<string, ServerConfig> = {}
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
76
|
+
Object.assign(servers, parsed.mcpServers ?? {})
|
|
77
|
+
} catch {
|
|
78
|
+
// missing or invalid file: skip silently, /mcp reports what loaded
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return servers
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatToolName(server: string, tool: string): string {
|
|
85
|
+
return `${server}_${tool}`.replace(/-/g, '_')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function normalizeSchema(schema: unknown): object {
|
|
89
|
+
const base = (schema as Record<string, unknown>) ?? {}
|
|
90
|
+
const { $schema: _dropSchema, additionalProperties: _dropAdditional, ...rest } = base
|
|
91
|
+
if (!rest.type) return { type: 'object', properties: {} }
|
|
92
|
+
return rest
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface McpContentBlock {
|
|
96
|
+
type: string
|
|
97
|
+
text?: string
|
|
98
|
+
data?: string
|
|
99
|
+
mimeType?: string
|
|
100
|
+
resource?: { uri?: string; text?: string }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
104
|
+
|
|
105
|
+
export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
|
|
106
|
+
if (!content || content.length === 0) {
|
|
107
|
+
return [{ type: 'text', text: structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)' }]
|
|
108
|
+
}
|
|
109
|
+
return content.map((block) => {
|
|
110
|
+
if (block.type === 'text') {
|
|
111
|
+
const text = block.text ?? ''
|
|
112
|
+
return text.length > MAX_INLINE_RESULT ? { type: 'text', text: `${text.slice(0, MAX_INLINE_RESULT)}\n[truncated ${text.length - MAX_INLINE_RESULT} chars]` } : { type: 'text', text }
|
|
113
|
+
}
|
|
114
|
+
if (block.type === 'image' && block.data) {
|
|
115
|
+
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
116
|
+
}
|
|
117
|
+
if (block.type === 'resource' && block.resource) {
|
|
118
|
+
return { type: 'text', text: `[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}` }
|
|
119
|
+
}
|
|
120
|
+
return { type: 'text', text: JSON.stringify(block) }
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
125
|
+
return 'command' in config
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
129
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
130
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
131
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
132
|
+
})
|
|
133
|
+
// If the timeout wins, `promise` stays pending; swallow any late rejection so it can never
|
|
134
|
+
// surface as an unhandled rejection that crashes the host.
|
|
135
|
+
promise.catch(() => {})
|
|
136
|
+
try {
|
|
137
|
+
return await Promise.race([promise, timeout])
|
|
138
|
+
} finally {
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
144
|
+
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
145
|
+
if (isStdio(config)) {
|
|
146
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) }
|
|
147
|
+
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
|
|
148
|
+
const transport = new StdioClientTransport({
|
|
149
|
+
command: config.command,
|
|
150
|
+
args: config.args ?? [],
|
|
151
|
+
env,
|
|
152
|
+
cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
|
|
153
|
+
stderr: 'ignore',
|
|
154
|
+
})
|
|
155
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
|
|
156
|
+
return client
|
|
157
|
+
}
|
|
158
|
+
const headers: Record<string, string> = {}
|
|
159
|
+
for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
|
|
160
|
+
const token = config.bearerToken ?? (config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined)
|
|
161
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
162
|
+
const url = new URL(interpolateEnv(config.url))
|
|
163
|
+
try {
|
|
164
|
+
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
|
|
165
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
|
|
166
|
+
return client
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (String(error).includes('Unauthorized')) throw error
|
|
169
|
+
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
170
|
+
const transport = new SSEClientTransport(url, { requestInit: { headers } })
|
|
171
|
+
await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
|
|
172
|
+
return fallback
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
|
|
177
|
+
const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
|
|
178
|
+
let cursor: string | undefined
|
|
179
|
+
do {
|
|
180
|
+
const page = await client.listTools({ cursor })
|
|
181
|
+
tools.push(...page.tools)
|
|
182
|
+
cursor = page.nextCursor
|
|
183
|
+
} while (cursor)
|
|
184
|
+
return tools
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
188
|
+
const clients = new Map<string, Client>()
|
|
189
|
+
const status = new Map<string, { state: string; tools: number }>()
|
|
190
|
+
const registered = new Set<string>()
|
|
191
|
+
|
|
192
|
+
async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
|
|
193
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
194
|
+
try {
|
|
195
|
+
const client = await connect(name, config)
|
|
196
|
+
clients.set(name, client)
|
|
197
|
+
const tools = await withTimeout(listAllTools(client), CONNECT_TIMEOUT_MS, `list tools ${name}`)
|
|
198
|
+
let count = 0
|
|
199
|
+
for (const tool of tools) {
|
|
200
|
+
const toolName = formatToolName(name, tool.name)
|
|
201
|
+
if (RESERVED_NAMES.has(toolName) || registered.has(toolName)) {
|
|
202
|
+
console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
registered.add(toolName)
|
|
206
|
+
count++
|
|
207
|
+
pi.registerTool({
|
|
208
|
+
name: toolName,
|
|
209
|
+
label: `${name}: ${tool.name}`,
|
|
210
|
+
description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
|
|
211
|
+
parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
|
|
212
|
+
async execute(_id, params) {
|
|
213
|
+
const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }), CALL_TIMEOUT_MS, toolName)
|
|
214
|
+
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
215
|
+
const details: { error?: string } = {}
|
|
216
|
+
if (result.isError) {
|
|
217
|
+
details.error = 'tool_error'
|
|
218
|
+
const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
|
|
219
|
+
content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
|
|
220
|
+
}
|
|
221
|
+
return { content, details }
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
status.set(name, { state: 'connected', tools: count })
|
|
226
|
+
} catch (error) {
|
|
227
|
+
status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// User config is the user's own, so connect it eagerly.
|
|
233
|
+
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
234
|
+
|
|
235
|
+
let projectConnected = false
|
|
236
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
237
|
+
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
238
|
+
if (!projectConnected && ctx.isProjectTrusted?.()) {
|
|
239
|
+
projectConnected = true
|
|
240
|
+
await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const connected = [...status.values()].filter((s) => s.state === 'connected')
|
|
244
|
+
const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
|
|
245
|
+
if (connected.length > 0 || failed.length > 0) {
|
|
246
|
+
const total = connected.reduce((sum, s) => sum + s.tools, 0)
|
|
247
|
+
const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
|
|
248
|
+
ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
pi.on('session_shutdown', async () => {
|
|
253
|
+
// Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
|
|
254
|
+
await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
pi.registerCommand('mcp', {
|
|
258
|
+
description: 'Show MCP server status and tools',
|
|
259
|
+
handler: async (_args, ctx) => {
|
|
260
|
+
if (status.size === 0) {
|
|
261
|
+
ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
|
|
265
|
+
ctx.ui.notify(lines.join('\n'), 'info')
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Extension
|
|
3
|
+
*
|
|
4
|
+
* Claude Code style persistent memory, per project. Memories live as markdown
|
|
5
|
+
* files under ~/.pi/agent/memory/<project-slug>/ with a MEMORY.md index whose
|
|
6
|
+
* content is injected into the system prompt each session. The agent manages
|
|
7
|
+
* memories through the memory tool (save / read / delete / list).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs'
|
|
11
|
+
import * as os from 'node:os'
|
|
12
|
+
import * as path from 'node:path'
|
|
13
|
+
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
15
|
+
import { Type } from 'typebox'
|
|
16
|
+
|
|
17
|
+
const INDEX_FILE = 'MEMORY.md'
|
|
18
|
+
|
|
19
|
+
export function projectSlug(cwd: string): string {
|
|
20
|
+
return cwd.replace(/[/\\]/g, '-').replace(/^-+/, '-')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function memoryDir(cwd: string): string {
|
|
24
|
+
return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(cwd))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function slugifyName(name: string): string {
|
|
28
|
+
return (
|
|
29
|
+
name
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replace(/^-+|-+$/g, '')
|
|
33
|
+
.slice(0, 64) || 'memory'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Add or replace this memory's line in the index, keyed by its markdown link target. */
|
|
38
|
+
export function upsertIndexLine(index: string, name: string, description: string): string {
|
|
39
|
+
const line = `- [${name}](${name}.md): ${description}`
|
|
40
|
+
const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
|
|
41
|
+
if (lines.length === 0 || !lines[0].startsWith('#')) lines.unshift('# Memory index')
|
|
42
|
+
lines.push(line)
|
|
43
|
+
return `${lines.join('\n')}\n`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function removeIndexLine(index: string, name: string): string {
|
|
47
|
+
const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
|
|
48
|
+
return lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MemoryParams = Type.Object({
|
|
52
|
+
action: StringEnum(['save', 'read', 'delete', 'list'] as const, { description: 'What to do' }),
|
|
53
|
+
name: Type.Optional(Type.String({ description: 'Short kebab-case memory name (save/read/delete)' })),
|
|
54
|
+
description: Type.Optional(Type.String({ description: 'One-line summary shown in the always-loaded index (save)' })),
|
|
55
|
+
content: Type.Optional(Type.String({ description: 'Full memory content in markdown (save)' })),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function readIndex(dir: string): string {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readFileSync(path.join(dir, INDEX_FILE), 'utf-8')
|
|
61
|
+
} catch {
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function memoryExtension(pi: ExtensionAPI) {
|
|
67
|
+
let dir = memoryDir(process.cwd())
|
|
68
|
+
|
|
69
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
70
|
+
dir = memoryDir(ctx.cwd)
|
|
71
|
+
const count = readIndex(dir)
|
|
72
|
+
.split('\n')
|
|
73
|
+
.filter((l) => l.startsWith('- ')).length
|
|
74
|
+
if (count > 0) ctx.ui.notify(`Memory: ${count} memories loaded`, 'info')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
pi.on('before_agent_start', async (event) => {
|
|
78
|
+
const index = readIndex(dir)
|
|
79
|
+
if (!index.trim()) return
|
|
80
|
+
return {
|
|
81
|
+
systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${index}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
pi.registerTool({
|
|
86
|
+
name: 'memory',
|
|
87
|
+
label: 'Memory',
|
|
88
|
+
description: 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Actions: save (name + description + content), read (name), delete (name), list.',
|
|
89
|
+
parameters: MemoryParams,
|
|
90
|
+
async execute(_id, params) {
|
|
91
|
+
const name = params.name ? slugifyName(params.name) : undefined
|
|
92
|
+
const indexPath = path.join(dir, INDEX_FILE)
|
|
93
|
+
|
|
94
|
+
if (params.action === 'save') {
|
|
95
|
+
if (!name || !params.description || !params.content) {
|
|
96
|
+
return { content: [{ type: 'text' as const, text: 'save requires name, description, and content.' }], details: {} }
|
|
97
|
+
}
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
99
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), params.content)
|
|
100
|
+
fs.writeFileSync(indexPath, upsertIndexLine(readIndex(dir), name, params.description))
|
|
101
|
+
return { content: [{ type: 'text' as const, text: `Saved memory ${name}.` }], details: {} }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (params.action === 'read') {
|
|
105
|
+
if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
|
|
106
|
+
try {
|
|
107
|
+
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
108
|
+
return { content: [{ type: 'text' as const, text: body }], details: {} }
|
|
109
|
+
} catch {
|
|
110
|
+
return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (params.action === 'delete') {
|
|
115
|
+
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
116
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
117
|
+
const remaining = removeIndexLine(readIndex(dir), name)
|
|
118
|
+
if (remaining) fs.writeFileSync(indexPath, remaining)
|
|
119
|
+
else fs.rmSync(indexPath, { force: true })
|
|
120
|
+
return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const index = readIndex(dir)
|
|
124
|
+
return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Notify Extension
|
|
3
|
+
*
|
|
4
|
+
* Sends a native terminal notification when Pi agent is done and waiting for input.
|
|
5
|
+
* Supports multiple terminal protocols:
|
|
6
|
+
* - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
|
|
7
|
+
* - OSC 99: Kitty
|
|
8
|
+
* - Windows toast: Windows Terminal (WSL)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
12
|
+
|
|
13
|
+
function windowsToastScript(title: string, body: string): string {
|
|
14
|
+
const type = 'Windows.UI.Notifications'
|
|
15
|
+
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`
|
|
16
|
+
const template = `[${type}.ToastTemplateType]::ToastText01`
|
|
17
|
+
const toast = `[${type}.ToastNotification]::new($xml)`
|
|
18
|
+
return [`${mgr} > $null`, `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`, `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`, `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`].join('; ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function notifyOSC777(title: string, body: string): void {
|
|
22
|
+
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function notifyOSC99(title: string, body: string): void {
|
|
26
|
+
// Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
|
|
27
|
+
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`)
|
|
28
|
+
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function notifyWindows(title: string, body: string): void {
|
|
32
|
+
const { execFile } = require('node:child_process')
|
|
33
|
+
// The callback captures a spawn failure (e.g. powershell.exe missing) instead of
|
|
34
|
+
// letting an unhandled 'error' event crash the host process.
|
|
35
|
+
execFile('powershell.exe', ['-NoProfile', '-Command', windowsToastScript(title, body)], () => {})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notify(title: string, body: string): void {
|
|
39
|
+
if (process.env.WT_SESSION) {
|
|
40
|
+
notifyWindows(title, body)
|
|
41
|
+
} else if (process.env.KITTY_WINDOW_ID) {
|
|
42
|
+
notifyOSC99(title, body)
|
|
43
|
+
} else {
|
|
44
|
+
notifyOSC777(title, body)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function (pi: ExtensionAPI) {
|
|
49
|
+
pi.on('agent_end', async () => {
|
|
50
|
+
notify('Pi', 'Ready for input')
|
|
51
|
+
})
|
|
52
|
+
}
|