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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # pi-code
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/pi-code)](https://www.npmjs.com/package/pi-code)
4
+ [![npm](https://img.shields.io/npm/dt/pi-code)](https://www.npmjs.com/package/pi-code)
5
+ [![GitHub](https://img.shields.io/github/license/ilovepixelart/pi-code)](https://github.com/ilovepixelart/pi-code/blob/main/LICENSE)
6
+ \
7
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
8
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
9
+ \
10
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
11
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
12
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=security_rating)](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
  ![pi-code demo](demos/hero.gif)
@@ -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 # from npm (when published)
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 ~/Documents/pi-code # local path, then /reload after edits
29
+ pi install ./pi-code # local checkout, then /reload after edits
13
30
  ```
14
31
 
15
- One `pi install`, everything below loads. Each feature is an extension under [`extensions/`](extensions).
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 | `.mcp.json`, `~/.claude.json`, `.pi/mcp.json`; stdio + HTTP | `mcp.ts` |
28
- | Subagents / Task | `~/.claude/agents` + project `.claude/agents`, background runs | `subagent/` |
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)). Personal config lives in [dot-pi](https://github.com/ilovepixelart/dot-pi) (`~/.pi/agent`).
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 locally with `pi install ~/Documents/pi-code`, then `/reload` after edits.
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 match = /^\s*-\s*(.+)$/.exec(lines[i])
47
- if (!match) break
48
- items.push(unquote(match[1].trim()))
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
- let inFence = false
66
- for (const line of content.split('\n')) {
67
- if (line.trimStart().startsWith('```')) {
68
- inFence = !inFence
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
- export default function (pi: ExtensionAPI) {
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)]
@@ -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
- const child = spawn('sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
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.isProjectTrusted?.() ?? false
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
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
22
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
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
- 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'])
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}`.replace(/-/g, '_')
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
- 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 }
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
- const env: Record<string, string> = { ...(process.env as Record<string, string>) }
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
- // User config is the user's own, so connect it eagerly.
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
- if (!projectConnected && ctx.isProjectTrusted?.()) {
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
  }
@@ -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
- .replace(/[^a-z0-9]+/g, '-')
32
- .replace(/^-+|-+$/g, '')
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
  }
@@ -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
- // 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)], () => {})
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(`^\\s*${key}\\s*:\\s*(.+)$`, 'm').exec(frontmatter)
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