pi-code 0.1.0 → 0.2.1
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 +24 -7
- package/extensions/claude-rules.ts +7 -3
- package/extensions/context-imports.ts +56 -29
- package/extensions/git-checkpoint.ts +16 -16
- package/extensions/hooks.ts +5 -2
- package/extensions/mcp.ts +29 -14
- package/extensions/memory.ts +4 -3
- package/extensions/notify.ts +6 -4
- package/extensions/output-guard.ts +23 -0
- package/extensions/output-styles.ts +1 -1
- package/extensions/plan-mode/index.ts +83 -58
- package/extensions/plan-mode/utils.ts +118 -16
- package/extensions/project-approval.ts +73 -0
- package/extensions/question.ts +63 -49
- package/extensions/subagent/agents.ts +12 -11
- package/extensions/subagent/index.ts +612 -465
- package/extensions/todo.ts +48 -31
- package/extensions/web.ts +49 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# pi-code
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-code)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-code)
|
|
5
|
+
[](https://github.com/ilovepixelart/pi-code/blob/main/LICENSE)
|
|
6
|
+
\
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
8
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
9
|
+
\
|
|
10
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
11
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
12
|
+
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
13
|
+
|
|
3
14
|
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and subagents.
|
|
4
15
|
|
|
5
16
|

|
|
@@ -7,12 +18,18 @@ Claude Code experience for the [pi](https://pi.dev) coding agent, in one package
|
|
|
7
18
|
## Install
|
|
8
19
|
|
|
9
20
|
```bash
|
|
10
|
-
pi install pi-code
|
|
21
|
+
pi install npm:pi-code # from npm
|
|
22
|
+
pi install -l npm:pi-code # project-local instead, writes .pi/settings.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Other sources:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
11
28
|
pi install git:github.com/ilovepixelart/pi-code
|
|
12
|
-
pi install
|
|
29
|
+
pi install ./pi-code # local checkout, then /reload after edits
|
|
13
30
|
```
|
|
14
31
|
|
|
15
|
-
One `pi install
|
|
32
|
+
One `pi install` and everything below loads on the next start. `pi list` shows what is installed, `pi config` toggles individual resources, and `pi update pi-code` upgrades it. Each feature is an extension under [`extensions/`](extensions).
|
|
16
33
|
|
|
17
34
|
## What it does
|
|
18
35
|
|
|
@@ -24,8 +41,8 @@ One `pi install`, everything below loads. Each feature is an extension under [`e
|
|
|
24
41
|
| Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
|
|
25
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
26
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
|
|
27
|
-
| MCP servers |
|
|
28
|
-
| Subagents / Task | `~/.claude/agents`
|
|
44
|
+
| MCP servers | `~/.claude.json`, `~/.pi/agent/mcp.json`, `.mcp.json`, `.pi/mcp.json` (later wins); stdio, HTTP, SSE | `mcp.ts` |
|
|
45
|
+
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
29
46
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
30
47
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
31
48
|
| Checkpoints / rewind | shadow-repo snapshots, hard-reset restore | `git-checkpoint.ts` |
|
|
@@ -37,7 +54,7 @@ One `pi install`, everything below loads. Each feature is an extension under [`e
|
|
|
37
54
|
|
|
38
55
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
|
|
39
56
|
|
|
40
|
-
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
57
|
+
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
41
58
|
|
|
42
59
|
## Development
|
|
43
60
|
|
|
@@ -48,4 +65,4 @@ scripts/e2e.sh # drives the real pi TUI via tmux (needs a working model
|
|
|
48
65
|
scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
|
|
49
66
|
```
|
|
50
67
|
|
|
51
|
-
Extensions live in `extensions/`, tests in `tests/`. Install
|
|
68
|
+
Extensions live in `extensions/`, tests in `tests/`. Install a local checkout with `pi install ./pi-code`, then `/reload` after edits.
|
|
@@ -42,10 +42,14 @@ function parsePaths(frontmatter: string): string[] {
|
|
|
42
42
|
const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
|
|
43
43
|
if (inline) return splitInline(inline)
|
|
44
44
|
const items: string[] = []
|
|
45
|
+
// Matched with string ops rather than a regex: the equivalent pattern needs two
|
|
46
|
+
// adjacent whitespace quantifiers, which backtracks super-linearly on long lines.
|
|
45
47
|
for (let i = index + 1; i < lines.length; i++) {
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
|
|
48
|
+
const entry = lines[i].trimStart()
|
|
49
|
+
if (!entry.startsWith('-')) break
|
|
50
|
+
const value = entry.slice(1).trim()
|
|
51
|
+
if (!value) break
|
|
52
|
+
items.push(unquote(value))
|
|
49
53
|
}
|
|
50
54
|
return items
|
|
51
55
|
}
|
|
@@ -54,6 +54,41 @@ export interface ImportedFile {
|
|
|
54
54
|
body: string
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The `@path` targets of a context file, in document order, skipping fenced code blocks. */
|
|
58
|
+
function importTargets(content: string): string[] {
|
|
59
|
+
const targets: string[] = []
|
|
60
|
+
let inFence = false
|
|
61
|
+
for (const line of content.split('\n')) {
|
|
62
|
+
if (line.trimStart().startsWith('```')) {
|
|
63
|
+
inFence = !inFence
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (inFence) continue
|
|
67
|
+
for (const match of line.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
|
|
68
|
+
}
|
|
69
|
+
return targets
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, or unreadable. */
|
|
73
|
+
function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>): { real: string; body: string } | null {
|
|
74
|
+
const resolved = path.resolve(fromDir, expandHome(target, home))
|
|
75
|
+
let real: string
|
|
76
|
+
try {
|
|
77
|
+
real = fs.realpathSync(resolved)
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
if (seen.has(real)) return null
|
|
82
|
+
seen.add(real)
|
|
83
|
+
if (!isUnder(real, allowedRoots)) return null
|
|
84
|
+
try {
|
|
85
|
+
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
86
|
+
return { real, body: fs.readFileSync(real, 'utf-8') }
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
/**
|
|
58
93
|
* Collect the contents of every file transitively imported via `@path`, in
|
|
59
94
|
* discovery order. Imports are resolved through symlinks and kept within
|
|
@@ -62,38 +97,29 @@ export interface ImportedFile {
|
|
|
62
97
|
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, depth = 0): ImportedFile[] {
|
|
63
98
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
64
99
|
const out: ImportedFile[] = []
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
if (inFence) continue
|
|
72
|
-
for (const match of line.matchAll(/(^|\s)@(\S+)/g)) {
|
|
73
|
-
const resolved = path.resolve(fromDir, expandHome(match[2], home))
|
|
74
|
-
let real: string
|
|
75
|
-
try {
|
|
76
|
-
real = fs.realpathSync(resolved)
|
|
77
|
-
} catch {
|
|
78
|
-
continue
|
|
79
|
-
}
|
|
80
|
-
if (seen.has(real)) continue
|
|
81
|
-
seen.add(real)
|
|
82
|
-
if (!isUnder(real, allowedRoots)) continue
|
|
83
|
-
let body: string
|
|
84
|
-
try {
|
|
85
|
-
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
86
|
-
body = fs.readFileSync(real, 'utf-8')
|
|
87
|
-
} catch {
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
out.push({ path: real, body: body.trim() })
|
|
91
|
-
out.push(...collectImports(body, path.dirname(real), home, allowedRoots, seen, depth + 1))
|
|
92
|
-
}
|
|
100
|
+
for (const target of importTargets(content)) {
|
|
101
|
+
const file = readImport(target, fromDir, home, allowedRoots, seen)
|
|
102
|
+
if (!file) continue
|
|
103
|
+
out.push({ path: file.real, body: file.body.trim() }, ...collectImports(file.body, path.dirname(file.real), home, allowedRoots, seen, depth + 1))
|
|
93
104
|
}
|
|
94
105
|
return out
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Roots an importing file may pull from.
|
|
110
|
+
*
|
|
111
|
+
* A context file under the user's own config may reach the whole config; a project
|
|
112
|
+
* file may not. `~/.claude` holds `.credentials.json`, global settings and every
|
|
113
|
+
* project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
|
|
114
|
+
* would let it read them into the system prompt.
|
|
115
|
+
*/
|
|
116
|
+
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
117
|
+
const userRoots = realRoots([path.join(home, '.claude'), path.join(home, '.pi')])
|
|
118
|
+
const [real] = realRoots([importer])
|
|
119
|
+
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
120
|
+
return fromUserConfig ? realRoots([cwd, ...userRoots]) : realRoots([cwd])
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
98
124
|
pi.on('before_agent_start', async (event) => {
|
|
99
125
|
const contextFiles: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
@@ -101,13 +127,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
101
127
|
|
|
102
128
|
const home = os.homedir()
|
|
103
129
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
104
|
-
const allowedRoots = realRoots([cwd, path.join(home, '.claude'), path.join(home, '.pi')])
|
|
105
130
|
// Seed with the loaded context file paths so pi's own files are never re-imported.
|
|
106
131
|
const seen = realRoots(contextFiles.map((file) => file.path))
|
|
107
132
|
const seenSet = new Set(seen)
|
|
108
133
|
|
|
109
134
|
const imported: ImportedFile[] = []
|
|
110
135
|
for (const file of contextFiles) {
|
|
136
|
+
// Roots are scoped per importing file: a project file never reaches user config.
|
|
137
|
+
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
111
138
|
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet))
|
|
112
139
|
}
|
|
113
140
|
if (imported.length === 0) return
|
|
@@ -65,7 +65,21 @@ function checkpointLabel(checkpoint: Checkpoint, index: number): string {
|
|
|
65
65
|
return `${index + 1}. ${time} ${checkpoint.prompt || '(empty prompt)'}${marker}`
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
async function restoreConversation(ctx: ExtensionCommandContext, entryId: string): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
// navigateTree's published type omits editorText, but it is present at runtime (docs/extensions.md)
|
|
71
|
+
const result = (await ctx.navigateTree(entryId, { summarize: false })) as { cancelled: boolean; editorText?: string }
|
|
72
|
+
if (result.cancelled) return false
|
|
73
|
+
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
74
|
+
return true
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
77
|
+
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
69
83
|
const checkpoints = new Map<string, Checkpoint>()
|
|
70
84
|
let pending: { ref: string; createdAt: string } | undefined
|
|
71
85
|
let shadowDir: string | undefined
|
|
@@ -117,20 +131,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
131
|
return true
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
async function restoreConversation(ctx: ExtensionCommandContext, entryId: string): Promise<boolean> {
|
|
121
|
-
try {
|
|
122
|
-
// navigateTree's published type omits editorText, but it is present at runtime (docs/extensions.md)
|
|
123
|
-
const result = (await ctx.navigateTree(entryId, { summarize: false })) as { cancelled: boolean; editorText?: string }
|
|
124
|
-
if (result.cancelled) return false
|
|
125
|
-
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
126
|
-
return true
|
|
127
|
-
} catch (error) {
|
|
128
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
129
|
-
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
130
|
-
return false
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
134
|
async function runRestoreMode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<void> {
|
|
135
135
|
const mode = await ctx.ui.select('Restore mode:', [...RESTORE_MODES])
|
|
136
136
|
if (!mode) return
|
|
@@ -178,7 +178,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
178
|
ctx.ui.notify('No checkpoints recorded yet', 'info')
|
|
179
179
|
return
|
|
180
180
|
}
|
|
181
|
-
const labels = ordered.map(checkpointLabel)
|
|
181
|
+
const labels = ordered.map((checkpoint, index) => checkpointLabel(checkpoint, index))
|
|
182
182
|
const choice = await ctx.ui.select('Rewind to checkpoint:', labels)
|
|
183
183
|
if (!choice) return
|
|
184
184
|
const checkpoint = ordered[labels.indexOf(choice)]
|
package/extensions/hooks.ts
CHANGED
|
@@ -27,6 +27,8 @@ import * as os from 'node:os'
|
|
|
27
27
|
import * as path from 'node:path'
|
|
28
28
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
29
29
|
|
|
30
|
+
import { isProjectApproved } from './project-approval.js'
|
|
31
|
+
|
|
30
32
|
const DEFAULT_TIMEOUT_S = 60
|
|
31
33
|
|
|
32
34
|
interface HookCommand {
|
|
@@ -112,7 +114,8 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
112
114
|
|
|
113
115
|
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
114
116
|
new Promise((resolve) => {
|
|
115
|
-
|
|
117
|
+
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
118
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
116
119
|
let stdout = ''
|
|
117
120
|
let stderr = ''
|
|
118
121
|
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
@@ -158,7 +161,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
158
161
|
let config: HooksConfig = {}
|
|
159
162
|
|
|
160
163
|
pi.on('session_start', async (event, ctx) => {
|
|
161
|
-
const trusted = ctx
|
|
164
|
+
const trusted = await isProjectApproved(ctx)
|
|
162
165
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
163
166
|
// Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
|
|
164
167
|
// "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
|
package/extensions/mcp.ts
CHANGED
|
@@ -18,16 +18,23 @@ import * as os from 'node:os'
|
|
|
18
18
|
import * as path from 'node:path'
|
|
19
19
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
20
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
22
|
+
// the old spec exist, so this stays as a fallback for the migration period.
|
|
23
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
|
|
24
|
+
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
23
25
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
24
26
|
import { Type } from 'typebox'
|
|
27
|
+
import { capForContext } from './output-guard.js'
|
|
28
|
+
import { isProjectApproved } from './project-approval.js'
|
|
25
29
|
|
|
26
30
|
const CONNECT_TIMEOUT_MS = 10_000
|
|
27
31
|
const CALL_TIMEOUT_MS = 120_000
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
32
|
+
// Tool names an MCP server must never take over. formatToolName always emits
|
|
33
|
+
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
34
|
+
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
35
|
+
// These are pi-code's own tools, and mcp.ts registers before the extensions owning
|
|
36
|
+
// them, so without this guard a server named `web` would replace the SSRF-checked fetch.
|
|
37
|
+
const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
|
|
31
38
|
|
|
32
39
|
export interface StdioServerConfig {
|
|
33
40
|
command: string
|
|
@@ -82,7 +89,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
export function formatToolName(server: string, tool: string): string {
|
|
85
|
-
return `${server}_${tool}`.
|
|
92
|
+
return `${server}_${tool}`.replaceAll('-', '_')
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
export function normalizeSchema(schema: unknown): object {
|
|
@@ -108,8 +115,7 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
|
|
|
108
115
|
}
|
|
109
116
|
return content.map((block) => {
|
|
110
117
|
if (block.type === 'text') {
|
|
111
|
-
|
|
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 }
|
|
118
|
+
return { type: 'text', text: capForContext(block.text ?? '') }
|
|
113
119
|
}
|
|
114
120
|
if (block.type === 'image' && block.data) {
|
|
115
121
|
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
@@ -143,7 +149,10 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): P
|
|
|
143
149
|
async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
144
150
|
const client = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
145
151
|
if (isStdio(config)) {
|
|
146
|
-
|
|
152
|
+
// Start from the SDK's allowlist (PATH, HOME, SHELL, ...) rather than the whole
|
|
153
|
+
// process env: a server should not receive ANTHROPIC_API_KEY or GITHUB_TOKEN just
|
|
154
|
+
// for being launched. A server that needs a variable names it in its own env block.
|
|
155
|
+
const env: Record<string, string> = { ...getDefaultEnvironment() }
|
|
147
156
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
|
|
148
157
|
const transport = new StdioClientTransport({
|
|
149
158
|
command: config.command,
|
|
@@ -167,7 +176,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
167
176
|
} catch (error) {
|
|
168
177
|
if (String(error).includes('Unauthorized')) throw error
|
|
169
178
|
const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
|
|
170
|
-
const transport = new SSEClientTransport(url, { requestInit: { headers } })
|
|
179
|
+
const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
|
|
171
180
|
await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
|
|
172
181
|
return fallback
|
|
173
182
|
}
|
|
@@ -229,13 +238,19 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
229
238
|
}
|
|
230
239
|
}
|
|
231
240
|
|
|
232
|
-
|
|
233
|
-
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
234
|
-
|
|
241
|
+
let userConnected = false
|
|
235
242
|
let projectConnected = false
|
|
243
|
+
|
|
236
244
|
pi.on('session_start', async (_event, ctx) => {
|
|
245
|
+
// Connecting spawns processes and opens sockets, so it belongs here rather than in
|
|
246
|
+
// the factory: pi runs the factory for invocations that never start a session.
|
|
247
|
+
if (!userConnected) {
|
|
248
|
+
userConnected = true
|
|
249
|
+
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
250
|
+
}
|
|
237
251
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
238
|
-
|
|
252
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
253
|
+
if (!projectConnected && (await isProjectApproved(ctx))) {
|
|
239
254
|
projectConnected = true
|
|
240
255
|
await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
|
|
241
256
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as path from 'node:path'
|
|
|
13
13
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
14
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
15
15
|
import { Type } from 'typebox'
|
|
16
|
+
import { capForContext } from './output-guard.js'
|
|
16
17
|
|
|
17
18
|
const INDEX_FILE = 'MEMORY.md'
|
|
18
19
|
|
|
@@ -28,8 +29,8 @@ export function slugifyName(name: string): string {
|
|
|
28
29
|
return (
|
|
29
30
|
name
|
|
30
31
|
.toLowerCase()
|
|
31
|
-
.
|
|
32
|
-
.
|
|
32
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
33
|
+
.replaceAll(/^-|-$/g, '')
|
|
33
34
|
.slice(0, 64) || 'memory'
|
|
34
35
|
)
|
|
35
36
|
}
|
|
@@ -105,7 +106,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
105
106
|
if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
|
|
106
107
|
try {
|
|
107
108
|
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
108
|
-
return { content: [{ type: 'text' as const, text: body }], details: {} }
|
|
109
|
+
return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
|
|
109
110
|
} catch {
|
|
110
111
|
return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
|
|
111
112
|
}
|
package/extensions/notify.ts
CHANGED
|
@@ -30,9 +30,11 @@ function notifyOSC99(title: string, body: string): void {
|
|
|
30
30
|
|
|
31
31
|
function notifyWindows(title: string, body: string): void {
|
|
32
32
|
const { execFile } = require('node:child_process')
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
33
|
+
// Resolve powershell from a fixed system path rather than through PATH, and let the callback
|
|
34
|
+
// capture a spawn failure instead of an unhandled 'error' event crashing the host.
|
|
35
|
+
const root = process.env.SystemRoot ?? String.raw`C:\Windows`
|
|
36
|
+
const powershell = String.raw`${root}\System32\WindowsPowerShell\v1.0\powershell.exe`
|
|
37
|
+
execFile(powershell, ['-NoProfile', '-Command', windowsToastScript(title, body)], () => {})
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function notify(title: string, body: string): void {
|
|
@@ -45,7 +47,7 @@ function notify(title: string, body: string): void {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export default function (pi: ExtensionAPI) {
|
|
50
|
+
export default function notifyExtension(pi: ExtensionAPI) {
|
|
49
51
|
pi.on('agent_end', async () => {
|
|
50
52
|
notify('Pi', 'Ready for input')
|
|
51
53
|
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Guard
|
|
3
|
+
*
|
|
4
|
+
* pi requires every tool to truncate its output, at 50KB or 2000 lines, whichever is hit
|
|
5
|
+
* first (docs/extensions.md, "Tool output"). Each tool used to decide that for itself, so
|
|
6
|
+
* the budgets and the truncation notices diverged and a byte-only cap let thousands of
|
|
7
|
+
* short lines through. This is the single place that decision lives.
|
|
8
|
+
*
|
|
9
|
+
* `truncateHead` keeps whole lines, which means a single line over the budget yields no
|
|
10
|
+
* content at all. That trap is handled here once rather than at each call site.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
|
|
14
|
+
|
|
15
|
+
/** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
|
|
16
|
+
export function capForContext(text: string): string {
|
|
17
|
+
const cut = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
|
18
|
+
if (!cut.truncated) return text
|
|
19
|
+
const kept = cut.content || text.slice(0, DEFAULT_MAX_BYTES)
|
|
20
|
+
const capped = `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total, ${cut.totalLines} lines]`
|
|
21
|
+
// Just over the budget, the notice can cost more than the trim saves.
|
|
22
|
+
return capped.length < text.length ? capped : text
|
|
23
|
+
}
|
|
@@ -26,7 +26,7 @@ export interface OutputStyle {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function field(frontmatter: string, key: string): string {
|
|
29
|
-
const match = new RegExp(
|
|
29
|
+
const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
|
|
30
30
|
return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
|
|
31
31
|
}
|
|
32
32
|
|