pi-code 0.2.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/extensions/hooks.ts +3 -1
- package/extensions/mcp.ts +5 -4
- package/extensions/memory.ts +2 -12
- package/extensions/output-guard.ts +23 -0
- package/extensions/plan-mode/utils.ts +49 -7
- package/extensions/project-approval.ts +73 -0
- package/extensions/subagent/index.ts +4 -1
- package/package.json +1 -1
- package/extensions/project-trust.ts +0 -65
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 {
|
|
@@ -159,7 +161,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
159
161
|
let config: HooksConfig = {}
|
|
160
162
|
|
|
161
163
|
pi.on('session_start', async (event, ctx) => {
|
|
162
|
-
const trusted = ctx
|
|
164
|
+
const trusted = await isProjectApproved(ctx)
|
|
163
165
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
164
166
|
// Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
|
|
165
167
|
// "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
|
package/extensions/mcp.ts
CHANGED
|
@@ -24,10 +24,11 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
|
|
|
24
24
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
25
25
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
26
26
|
import { Type } from 'typebox'
|
|
27
|
+
import { capForContext } from './output-guard.js'
|
|
28
|
+
import { isProjectApproved } from './project-approval.js'
|
|
27
29
|
|
|
28
30
|
const CONNECT_TIMEOUT_MS = 10_000
|
|
29
31
|
const CALL_TIMEOUT_MS = 120_000
|
|
30
|
-
const MAX_INLINE_RESULT = 50_000
|
|
31
32
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
32
33
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
33
34
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -114,8 +115,7 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
|
|
|
114
115
|
}
|
|
115
116
|
return content.map((block) => {
|
|
116
117
|
if (block.type === 'text') {
|
|
117
|
-
|
|
118
|
-
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 ?? '') }
|
|
119
119
|
}
|
|
120
120
|
if (block.type === 'image' && block.data) {
|
|
121
121
|
return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
|
|
@@ -249,7 +249,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
249
249
|
await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
|
|
250
250
|
}
|
|
251
251
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
252
|
-
|
|
252
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
253
|
+
if (!projectConnected && (await isProjectApproved(ctx))) {
|
|
253
254
|
projectConnected = true
|
|
254
255
|
await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
|
|
255
256
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -11,8 +11,9 @@ import * as fs from 'node:fs'
|
|
|
11
11
|
import * as os from 'node:os'
|
|
12
12
|
import * as path from 'node:path'
|
|
13
13
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
14
|
-
import {
|
|
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
|
|
|
@@ -63,17 +64,6 @@ function readIndex(dir: string): string {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
-
* Keep a memory inside pi's context budget. truncateHead keeps whole lines, so a
|
|
68
|
-
* single oversized line yields nothing; fall back to a hard slice in that case.
|
|
69
|
-
*/
|
|
70
|
-
function capForContext(body: string): string {
|
|
71
|
-
const cut = truncateHead(body, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
|
72
|
-
if (!cut.truncated) return body
|
|
73
|
-
const kept = cut.content || body.slice(0, DEFAULT_MAX_BYTES)
|
|
74
|
-
return `${kept}\n\n[truncated: ${formatSize(cut.totalBytes)} total]`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
67
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
78
68
|
let dir = memoryDir(process.cwd())
|
|
79
69
|
|
|
@@ -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
|
+
}
|
|
@@ -94,9 +94,54 @@ const SAFE_PATTERNS = [
|
|
|
94
94
|
// outright rather than parsed.
|
|
95
95
|
const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
|
|
99
|
+
* newline) so every subcommand is checked on its own, ignoring separators inside quotes:
|
|
100
|
+
* `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
|
|
101
|
+
* fails the caller closed rather than guessing at the intended split.
|
|
102
|
+
*
|
|
103
|
+
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
104
|
+
*/
|
|
105
|
+
function splitSegments(command: string): string[] {
|
|
106
|
+
const segments: string[] = []
|
|
107
|
+
let current = ''
|
|
108
|
+
let quote: "'" | '"' | undefined
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < command.length; i++) {
|
|
111
|
+
const ch = command[i]
|
|
112
|
+
if (quote !== undefined) {
|
|
113
|
+
current += ch
|
|
114
|
+
if (ch === quote) quote = undefined
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (ch === "'" || ch === '"') {
|
|
118
|
+
quote = ch
|
|
119
|
+
current += ch
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
123
|
+
current += ch + command[++i]
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const pair = command.slice(i, i + 2)
|
|
127
|
+
if (pair === '&&' || pair === '||' || pair === '|&') {
|
|
128
|
+
segments.push(current)
|
|
129
|
+
current = ''
|
|
130
|
+
i++
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
|
|
134
|
+
segments.push(current)
|
|
135
|
+
current = ''
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
current += ch
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (quote !== undefined) return []
|
|
142
|
+
segments.push(current)
|
|
143
|
+
return segments.map((segment) => segment.trim()).filter(Boolean)
|
|
144
|
+
}
|
|
100
145
|
|
|
101
146
|
// find is allowlisted for traversal only; these actions run commands or delete.
|
|
102
147
|
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
@@ -116,10 +161,7 @@ function isSafeSegment(segment: string): boolean {
|
|
|
116
161
|
*/
|
|
117
162
|
export function isSafeCommand(command: string): boolean {
|
|
118
163
|
if (SUBSTITUTION.test(command)) return false
|
|
119
|
-
const segments = command
|
|
120
|
-
.split(SEPARATORS)
|
|
121
|
-
.map((s) => s.trim())
|
|
122
|
-
.filter(Boolean)
|
|
164
|
+
const segments = splitSegments(command)
|
|
123
165
|
return segments.length > 0 && segments.every(isSafeSegment)
|
|
124
166
|
}
|
|
125
167
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Approval
|
|
3
|
+
*
|
|
4
|
+
* `ctx.isProjectTrusted()` is not sufficient on its own. pi decides whether to ask for
|
|
5
|
+
* trust in `hasTrustRequiringProjectResources`, which looks only under `cwd/.pi` and for
|
|
6
|
+
* `.agents/skills`. A repository shipping just `.claude/` and `.mcp.json` matches neither,
|
|
7
|
+
* so `resolveProjectTrusted` short-circuits to `true` before it ever emits `project_trust`:
|
|
8
|
+
*
|
|
9
|
+
* if (!hasTrustRequiringProjectResources(cwd)) return true
|
|
10
|
+
* if (extensionsResult) { ...emitProjectTrustEvent... }
|
|
11
|
+
*
|
|
12
|
+
* A `project_trust` handler therefore cannot cover this case; the event only fires for
|
|
13
|
+
* projects pi was already going to prompt about. The decision has to be made where the
|
|
14
|
+
* project config is consumed instead, which is what this module does.
|
|
15
|
+
*
|
|
16
|
+
* Answers are stored in pi's own trust store, so approving here also satisfies pi if the
|
|
17
|
+
* project later grows `.pi` resources, and a decision recorded on a parent directory applies.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
23
|
+
|
|
24
|
+
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
25
|
+
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
26
|
+
|
|
27
|
+
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
28
|
+
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApprovalContext {
|
|
32
|
+
cwd: string
|
|
33
|
+
hasUI: boolean
|
|
34
|
+
isProjectTrusted?: () => boolean
|
|
35
|
+
ui: { confirm: (title: string, body: string) => Promise<boolean> }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApprovalDeps {
|
|
39
|
+
hasClaudeShaped: (cwd: string) => boolean
|
|
40
|
+
piWouldAsk: (cwd: string) => boolean
|
|
41
|
+
savedDecision: (cwd: string) => boolean | null
|
|
42
|
+
remember: (cwd: string, trusted: boolean) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultDeps: ApprovalDeps = {
|
|
46
|
+
hasClaudeShaped: hasClaudeShapedConfig,
|
|
47
|
+
piWouldAsk: hasTrustRequiringProjectResources,
|
|
48
|
+
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
49
|
+
remember: (cwd, trusted) => new ProjectTrustStore(getAgentDir()).set(cwd, trusted),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MCP servers, hooks and agents can run commands from this repository.'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether project-controlled config may be acted on.
|
|
56
|
+
*
|
|
57
|
+
* Refuses without a UI rather than deferring: pi reached this point without consulting
|
|
58
|
+
* `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
|
|
59
|
+
* that cannot ask has not been approved.
|
|
60
|
+
*/
|
|
61
|
+
export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
|
|
62
|
+
if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
|
|
63
|
+
if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
|
|
64
|
+
if (deps.piWouldAsk(ctx.cwd)) return true // pi genuinely prompted for this project
|
|
65
|
+
|
|
66
|
+
const stored = deps.savedDecision(ctx.cwd)
|
|
67
|
+
if (stored !== null) return stored
|
|
68
|
+
if (!ctx.hasUI) return false
|
|
69
|
+
|
|
70
|
+
const approved = await ctx.ui.confirm('Trust this project?', `${ctx.cwd}\n\n${APPROVAL_BODY}`)
|
|
71
|
+
deps.remember(ctx.cwd, approved)
|
|
72
|
+
return approved
|
|
73
|
+
}
|
|
@@ -22,6 +22,7 @@ import { StringEnum } from '@earendil-works/pi-ai'
|
|
|
22
22
|
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
24
24
|
import { type Static, Type } from 'typebox'
|
|
25
|
+
import { isProjectApproved } from '../project-approval.js'
|
|
25
26
|
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
26
27
|
import { backgroundStatusText, startBackgroundRun } from './background.js'
|
|
27
28
|
|
|
@@ -484,7 +485,9 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
484
485
|
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
485
486
|
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
486
487
|
|
|
487
|
-
|
|
488
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
489
|
+
const approved = await isProjectApproved(ctx)
|
|
490
|
+
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
488
491
|
const names = requestedProjectAgents.map((a) => a.name).join(', ')
|
|
489
492
|
if (gate === 'refuse') {
|
|
490
493
|
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project Trust Extension
|
|
3
|
-
*
|
|
4
|
-
* pi decides whether to prompt for trust from `hasTrustRequiringProjectResources`,
|
|
5
|
-
* which looks only at entries under `cwd/.pi` and at `.agents/skills`. A repository
|
|
6
|
-
* that ships only Claude Code shaped config, `.claude/` plus `.mcp.json`, matches
|
|
7
|
-
* none of them, so pi trusts it without asking.
|
|
8
|
-
*
|
|
9
|
-
* That is exactly the shape pi-code exists to load, and the other extensions gate
|
|
10
|
-
* their project input on `ctx.isProjectTrusted()`. Without this handler that flag is
|
|
11
|
-
* true for a freshly cloned repo nobody was asked about, and a project `.mcp.json`
|
|
12
|
-
* server command, project hooks and project agents all run.
|
|
13
|
-
*
|
|
14
|
-
* Only user/global and CLI extensions receive `project_trust`, so this works when
|
|
15
|
-
* pi-code is installed with `pi install npm:pi-code`. A project-local install
|
|
16
|
-
* (`pi install -l`) is not loaded until trust is already resolved.
|
|
17
|
-
*
|
|
18
|
-
* Docs: node_modules/@earendil-works/pi-coding-agent/docs/extensions.md (project_trust)
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import * as fs from 'node:fs'
|
|
22
|
-
import * as path from 'node:path'
|
|
23
|
-
import { type ExtensionAPI, getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
24
|
-
|
|
25
|
-
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
26
|
-
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
27
|
-
|
|
28
|
-
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
29
|
-
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export type TrustDecision = { trusted: 'yes' | 'no' | 'undecided'; remember?: boolean }
|
|
33
|
-
|
|
34
|
-
interface TrustDeps {
|
|
35
|
-
hasClaudeShaped: (cwd: string) => boolean
|
|
36
|
-
piWouldAsk: (cwd: string) => boolean
|
|
37
|
-
savedDecision: (cwd: string) => boolean | null
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const defaultDeps: TrustDeps = {
|
|
41
|
-
hasClaudeShaped: hasClaudeShapedConfig,
|
|
42
|
-
piWouldAsk: hasTrustRequiringProjectResources,
|
|
43
|
-
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Whether to take over the trust decision for this project.
|
|
48
|
-
*
|
|
49
|
-
* Returns `undecided` wherever pi already resolves things correctly, so a remembered
|
|
50
|
-
* decision is never overridden and a headless run keeps whatever `defaultProjectTrust`
|
|
51
|
-
* says rather than being forced closed.
|
|
52
|
-
*/
|
|
53
|
-
export async function decideTrust(cwd: string, hasUI: boolean, confirm: (title: string, body: string) => Promise<boolean>, deps: TrustDeps = defaultDeps): Promise<TrustDecision> {
|
|
54
|
-
if (!deps.hasClaudeShaped(cwd)) return { trusted: 'undecided' }
|
|
55
|
-
if (deps.piWouldAsk(cwd)) return { trusted: 'undecided' } // pi prompts on its own
|
|
56
|
-
if (deps.savedDecision(cwd) !== null) return { trusted: 'undecided' } // apply the stored answer
|
|
57
|
-
if (!hasUI) return { trusted: 'undecided' } // cannot ask; leave pi's default in charge
|
|
58
|
-
|
|
59
|
-
const approved = await confirm('Trust this project?', `${cwd}\n\nIt ships Claude Code configuration that pi-code loads: MCP servers, hooks and agents can run commands from this repository.`)
|
|
60
|
-
return { trusted: approved ? 'yes' : 'no', remember: true }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export default function projectTrustExtension(pi: ExtensionAPI) {
|
|
64
|
-
pi.on('project_trust', async (event, ctx) => decideTrust(event.cwd, ctx.hasUI, (title, body) => ctx.ui.confirm(title, body)))
|
|
65
|
-
}
|