pi-code 0.2.1 → 0.2.2
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.
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
* Claude Code's `@path` imports inside them. This fills that one gap: on
|
|
6
6
|
* before_agent_start it reads the already-loaded context files from
|
|
7
7
|
* systemPromptOptions, resolves any `@path` imports (recursive, depth-capped,
|
|
8
|
-
* cycle-safe; ~ expands to home, relative paths resolve against
|
|
9
|
-
* file), and appends ONLY the imported content. pi already
|
|
10
|
-
* files, so nothing is duplicated.
|
|
8
|
+
* cycle-safe, budget-capped; ~ expands to home, relative paths resolve against
|
|
9
|
+
* the importing file), and appends ONLY the imported content. pi already
|
|
10
|
+
* injected the base files, so nothing is duplicated.
|
|
11
11
|
*
|
|
12
12
|
* Security: context files can come from an untrusted project, so imports are
|
|
13
13
|
* confined (after resolving symlinks) to the working directory and the user's
|
|
14
14
|
* own ~/.claude and ~/.pi config roots. An import that escapes those roots
|
|
15
15
|
* (absolute paths, ~/.ssh, ../.. traversal, symlinks) is ignored, so a hostile
|
|
16
16
|
* CLAUDE.md cannot read arbitrary files into the prompt. Imports inside fenced
|
|
17
|
-
* code blocks are also skipped.
|
|
17
|
+
* code blocks are also skipped. One byte-and-file budget is shared by the whole
|
|
18
|
+
* run, so a context file cannot flood the prompt by importing breadth-first;
|
|
19
|
+
* what the budget refused is stated in the prompt rather than dropped silently.
|
|
18
20
|
*
|
|
19
21
|
* Docs: https://code.claude.com/docs/en/memory.md (imports)
|
|
20
22
|
*/
|
|
@@ -25,6 +27,8 @@ import * as path from 'node:path'
|
|
|
25
27
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
28
|
|
|
27
29
|
const MAX_IMPORT_DEPTH = 5
|
|
30
|
+
export const MAX_IMPORT_FILES = 50
|
|
31
|
+
export const MAX_IMPORT_BYTES = 256 * 1024
|
|
28
32
|
|
|
29
33
|
export function expandHome(target: string, home: string): string {
|
|
30
34
|
if (target === '~') return home
|
|
@@ -54,6 +58,18 @@ export interface ImportedFile {
|
|
|
54
58
|
body: string
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** Appended to the last body the byte budget could only partly pay for. */
|
|
62
|
+
export const IMPORT_TRUNCATED_MARKER = '[truncated: import byte budget exhausted]'
|
|
63
|
+
|
|
64
|
+
/** Remaining import allowance, shared across every context file of one run. */
|
|
65
|
+
export interface ImportBudget {
|
|
66
|
+
files: number
|
|
67
|
+
bytes: number
|
|
68
|
+
dropped: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
|
|
72
|
+
|
|
57
73
|
/** The `@path` targets of a context file, in document order, skipping fenced code blocks. */
|
|
58
74
|
function importTargets(content: string): string[] {
|
|
59
75
|
const targets: string[] = []
|
|
@@ -94,13 +110,22 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
94
110
|
* discovery order. Imports are resolved through symlinks and kept within
|
|
95
111
|
* `allowedRoots` (which must already be realpath'd).
|
|
96
112
|
*/
|
|
97
|
-
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, depth = 0): ImportedFile[] {
|
|
113
|
+
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0): ImportedFile[] {
|
|
98
114
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
99
115
|
const out: ImportedFile[] = []
|
|
100
116
|
for (const target of importTargets(content)) {
|
|
117
|
+
// Checked before the read so an exhausted budget costs no I/O.
|
|
118
|
+
if (budget.files === 0 || budget.bytes === 0) {
|
|
119
|
+
budget.dropped += 1
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
101
122
|
const file = readImport(target, fromDir, home, allowedRoots, seen)
|
|
102
123
|
if (!file) continue
|
|
103
|
-
|
|
124
|
+
budget.files -= 1
|
|
125
|
+
const kept = file.body.slice(0, budget.bytes)
|
|
126
|
+
budget.bytes -= kept.length
|
|
127
|
+
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
128
|
+
out.push({ path: file.real, body }, ...collectImports(kept, path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1))
|
|
104
129
|
}
|
|
105
130
|
return out
|
|
106
131
|
}
|
|
@@ -132,14 +157,17 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
132
157
|
const seenSet = new Set(seen)
|
|
133
158
|
|
|
134
159
|
const imported: ImportedFile[] = []
|
|
160
|
+
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
161
|
+
const budget = createImportBudget()
|
|
135
162
|
for (const file of contextFiles) {
|
|
136
163
|
// Roots are scoped per importing file: a project file never reaches user config.
|
|
137
164
|
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
138
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet))
|
|
165
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
|
|
139
166
|
}
|
|
140
167
|
if (imported.length === 0) return
|
|
141
168
|
|
|
142
169
|
const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
|
|
143
|
-
|
|
170
|
+
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
171
|
+
return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}${notice}` }
|
|
144
172
|
})
|
|
145
173
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Docs: https://code.claude.com/docs/en/hooks.md
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { spawn } from 'node:child_process'
|
|
24
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
25
25
|
import * as fs from 'node:fs'
|
|
26
26
|
import * as os from 'node:os'
|
|
27
27
|
import * as path from 'node:path'
|
|
@@ -50,6 +50,8 @@ export interface HookRunResult {
|
|
|
50
50
|
code: number
|
|
51
51
|
stdout: string
|
|
52
52
|
stderr: string
|
|
53
|
+
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
54
|
+
timedOut: boolean
|
|
53
55
|
}
|
|
54
56
|
export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
55
57
|
|
|
@@ -112,27 +114,64 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
112
114
|
return { block: false }
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
|
|
118
|
+
const MAX_HOOK_OUTPUT = 1_000_000
|
|
119
|
+
|
|
120
|
+
/** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
|
|
121
|
+
const TIMEOUT_EXIT_CODE = 124
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
|
|
125
|
+
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
126
|
+
*/
|
|
127
|
+
function killTree(child: ChildProcess): void {
|
|
128
|
+
try {
|
|
129
|
+
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
130
|
+
if (child.pid) {
|
|
131
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Group already reaped, or the platform refused it; fall through to the direct kill.
|
|
136
|
+
}
|
|
137
|
+
child.kill('SIGKILL')
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
|
|
116
141
|
new Promise((resolve) => {
|
|
117
142
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
118
|
-
|
|
143
|
+
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
144
|
+
// the descendants too.
|
|
145
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
|
119
146
|
let stdout = ''
|
|
120
147
|
let stderr = ''
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
child.stderr?.on('data', (chunk) => {
|
|
126
|
-
stderr += chunk
|
|
127
|
-
})
|
|
128
|
-
child.on('close', (code) => {
|
|
148
|
+
let settled = false
|
|
149
|
+
const finish = (result: HookRunResult): void => {
|
|
150
|
+
if (settled) return
|
|
151
|
+
settled = true
|
|
129
152
|
clearTimeout(timer)
|
|
130
|
-
resolve(
|
|
153
|
+
resolve(result)
|
|
154
|
+
}
|
|
155
|
+
// Resolve from the timer itself rather than waiting for `close`: `close` fires only
|
|
156
|
+
// once every stdio pipe is closed, and a grandchild that inherited them can hold the
|
|
157
|
+
// promise pending long past the timeout, stalling the tool call that awaits it.
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
killTree(child)
|
|
160
|
+
finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
|
|
161
|
+
}, timeoutMs)
|
|
162
|
+
// Decode on the stream: concatenating Buffers as strings mangles a multi-byte
|
|
163
|
+
// character split across chunks, and a mangled byte in a hook's deny decision makes
|
|
164
|
+
// it unparseable, which reads as an allow.
|
|
165
|
+
child.stdout?.setEncoding('utf8')
|
|
166
|
+
child.stderr?.setEncoding('utf8')
|
|
167
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
168
|
+
if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
|
|
131
169
|
})
|
|
132
|
-
child.on('
|
|
133
|
-
|
|
134
|
-
resolve({ code: 0, stdout, stderr })
|
|
170
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
171
|
+
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
135
172
|
})
|
|
173
|
+
child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
|
|
174
|
+
child.on('error', () => finish({ code: 0, stdout, stderr, timedOut: false }))
|
|
136
175
|
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
137
176
|
// so ignore EPIPE on this write rather than crashing the host process.
|
|
138
177
|
child.stdin?.on('error', () => {})
|
|
@@ -147,6 +186,9 @@ function timeoutMs(command: HookCommand): number {
|
|
|
147
186
|
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
|
|
148
187
|
for (const command of matchingCommands(config.PreToolUse, toolName)) {
|
|
149
188
|
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
|
|
189
|
+
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
190
|
+
// would otherwise read as a clean allow. Fail closed instead.
|
|
191
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
|
|
150
192
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
151
193
|
if (decision.block) return decision
|
|
152
194
|
}
|
|
@@ -102,6 +102,14 @@ const SUBSTITUTION = /\$\(|`|<\(|>\(/
|
|
|
102
102
|
*
|
|
103
103
|
* A shell AST would be exact; this is the honest approximation for a quoting-only concern.
|
|
104
104
|
*/
|
|
105
|
+
/** Length of the separator at `i`, or 0 when there is none. */
|
|
106
|
+
function separatorAt(command: string, i: number): number {
|
|
107
|
+
const pair = command.slice(i, i + 2)
|
|
108
|
+
if (pair === '&&' || pair === '||' || pair === '|&') return 2
|
|
109
|
+
const ch = command[i]
|
|
110
|
+
return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
function splitSegments(command: string): string[] {
|
|
106
114
|
const segments: string[] = []
|
|
107
115
|
let current = ''
|
|
@@ -123,16 +131,11 @@ function splitSegments(command: string): string[] {
|
|
|
123
131
|
current += ch + command[++i]
|
|
124
132
|
continue
|
|
125
133
|
}
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
segments.push(current)
|
|
129
|
-
current = ''
|
|
130
|
-
i++
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
|
|
134
|
+
const separator = separatorAt(command, i)
|
|
135
|
+
if (separator > 0) {
|
|
134
136
|
segments.push(current)
|
|
135
137
|
current = ''
|
|
138
|
+
i += separator - 1
|
|
136
139
|
continue
|
|
137
140
|
}
|
|
138
141
|
current += ch
|
|
@@ -99,12 +99,35 @@ function isDirectory(p: string): boolean {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Project root at or above `from`. `.git` is a file in worktrees and submodules. */
|
|
103
|
+
const ROOT_MARKERS = ['.git', 'package.json']
|
|
104
|
+
|
|
105
|
+
function repoRoot(from: string): string | undefined {
|
|
106
|
+
let currentDir = from
|
|
107
|
+
while (true) {
|
|
108
|
+
if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
|
|
109
|
+
const parentDir = path.dirname(currentDir)
|
|
110
|
+
if (parentDir === currentDir) return undefined
|
|
111
|
+
currentDir = parentDir
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Nearest `relative` directory at or above `cwd`, stopping at the repository root.
|
|
117
|
+
*
|
|
118
|
+
* Without the boundary the search runs to the filesystem root, so an agent planted in a
|
|
119
|
+
* world-writable ancestor such as /tmp is offered as a project agent for every session
|
|
120
|
+
* beneath it. With no project marker (.git, package.json) the extent is unknown, so only
|
|
121
|
+
* `cwd` is considered.
|
|
122
|
+
*/
|
|
102
123
|
function findNearestDir(cwd: string, relative: string): string | null {
|
|
124
|
+
const boundary = repoRoot(cwd) ?? cwd
|
|
103
125
|
let currentDir = cwd
|
|
104
126
|
while (true) {
|
|
105
127
|
const candidate = path.join(currentDir, relative)
|
|
106
128
|
if (isDirectory(candidate)) return candidate
|
|
107
129
|
|
|
130
|
+
if (currentDir === boundary) return null
|
|
108
131
|
const parentDir = path.dirname(currentDir)
|
|
109
132
|
if (parentDir === currentDir) return null
|
|
110
133
|
currentDir = parentDir
|
|
@@ -488,12 +488,16 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
488
488
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
489
489
|
const approved = await isProjectApproved(ctx)
|
|
490
490
|
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
491
|
-
|
|
491
|
+
// Agent names come from repo-controlled frontmatter; a newline in one would otherwise
|
|
492
|
+
// let it write its own "Source:" line into the prompt body.
|
|
493
|
+
const names = requestedProjectAgents.map((a) => a.name.replace(/\s+/g, ' ').trim()).join(', ')
|
|
492
494
|
if (gate === 'refuse') {
|
|
493
495
|
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
|
494
496
|
}
|
|
495
497
|
if (gate === 'confirm') {
|
|
496
|
-
|
|
498
|
+
// Each agent knows where it was loaded from; projectAgentsDir only ever held .pi/agents.
|
|
499
|
+
const dirs = [...new Set(requestedProjectAgents.map((a) => path.dirname(a.filePath)))]
|
|
500
|
+
const dir = dirs.join(', ') || projectAgentsDir || '(unknown)'
|
|
497
501
|
const ok = await ctx.ui.confirm('Run project-local agents?', `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`)
|
|
498
502
|
if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
|
|
499
503
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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"
|