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 the importing
9
- * file), and appends ONLY the imported content. pi already injected the base
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
- out.push({ path: file.real, body: file.body.trim() }, ...collectImports(file.body, path.dirname(file.real), home, allowedRoots, seen, depth + 1))
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
- return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}` }
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
  }
@@ -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
- const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
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
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
122
- child.stdout?.on('data', (chunk) => {
123
- stdout += chunk
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({ code: code ?? 0, stdout, stderr })
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('error', () => {
133
- clearTimeout(timer)
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 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
+ 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
- const names = requestedProjectAgents.map((a) => a.name).join(', ')
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
- const dir = projectAgentsDir ?? '(unknown)'
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.1",
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"