pi-code 0.1.0 → 0.2.0

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)]
@@ -112,7 +112,8 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
112
112
 
113
113
  export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
114
114
  new Promise((resolve) => {
115
- const child = spawn('sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
115
+ // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
116
+ const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'] })
116
117
  let stdout = ''
117
118
  let stderr = ''
118
119
  const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
package/extensions/mcp.ts CHANGED
@@ -18,16 +18,22 @@ 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'
25
27
 
26
28
  const CONNECT_TIMEOUT_MS = 10_000
27
29
  const CALL_TIMEOUT_MS = 120_000
28
30
  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'])
31
+ // Tool names an MCP server must never take over. formatToolName always emits
32
+ // `<server>_<tool>`, so only names containing an underscore are actually reachable:
33
+ // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
34
+ // These are pi-code's own tools, and mcp.ts registers before the extensions owning
35
+ // them, so without this guard a server named `web` would replace the SSRF-checked fetch.
36
+ const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
31
37
 
32
38
  export interface StdioServerConfig {
33
39
  command: string
@@ -82,7 +88,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
82
88
  }
83
89
 
84
90
  export function formatToolName(server: string, tool: string): string {
85
- return `${server}_${tool}`.replace(/-/g, '_')
91
+ return `${server}_${tool}`.replaceAll('-', '_')
86
92
  }
87
93
 
88
94
  export function normalizeSchema(schema: unknown): object {
@@ -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,11 +238,16 @@ 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
252
  if (!projectConnected && ctx.isProjectTrusted?.()) {
239
253
  projectConnected = true
@@ -11,7 +11,7 @@ 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 type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
14
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type ExtensionAPI, formatSize, truncateHead } from '@earendil-works/pi-coding-agent'
15
15
  import { Type } from 'typebox'
16
16
 
17
17
  const INDEX_FILE = 'MEMORY.md'
@@ -28,8 +28,8 @@ export function slugifyName(name: string): string {
28
28
  return (
29
29
  name
30
30
  .toLowerCase()
31
- .replace(/[^a-z0-9]+/g, '-')
32
- .replace(/^-+|-+$/g, '')
31
+ .replaceAll(/[^a-z0-9]+/g, '-')
32
+ .replaceAll(/^-|-$/g, '')
33
33
  .slice(0, 64) || 'memory'
34
34
  )
35
35
  }
@@ -63,6 +63,17 @@ function readIndex(dir: string): string {
63
63
  }
64
64
  }
65
65
 
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
+
66
77
  export default function memoryExtension(pi: ExtensionAPI) {
67
78
  let dir = memoryDir(process.cwd())
68
79
 
@@ -105,7 +116,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
105
116
  if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
106
117
  try {
107
118
  const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
108
- return { content: [{ type: 'text' as const, text: body }], details: {} }
119
+ return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
109
120
  } catch {
110
121
  return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
111
122
  }
@@ -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
  })
@@ -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
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { AgentMessage } from '@earendil-works/pi-agent-core'
16
16
  import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
17
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
17
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
18
18
  import { Key } from '@earendil-works/pi-tui'
19
19
  import { Type } from 'typebox'
20
20
  import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
@@ -35,6 +35,23 @@ function getTextContent(message: AssistantMessage): string {
35
35
  .join('\n')
36
36
  }
37
37
 
38
+ // Last element matching the predicate (the lib target predates Array.prototype.findLast)
39
+ function findLast<T>(items: T[], match: (item: T) => boolean): T | undefined {
40
+ for (let i = items.length - 1; i >= 0; i--) {
41
+ if (match(items[i])) return items[i]
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ // Index of the last plan-mode-execute entry, or -1 when the current run never started one
47
+ function findLastExecuteIndex(entries: SessionEntry[]): number {
48
+ for (let i = entries.length - 1; i >= 0; i--) {
49
+ const entry = entries[i] as { customType?: string }
50
+ if (entry.customType === 'plan-mode-execute') return i
51
+ }
52
+ return -1
53
+ }
54
+
38
55
  export default function planModeExtension(pi: ExtensionAPI): void {
39
56
  let planModeEnabled = false
40
57
  let executionMode = false
@@ -108,6 +125,62 @@ export default function planModeExtension(pi: ExtensionAPI): void {
108
125
  })
109
126
  }
110
127
 
128
+ // Announce completion and reset once every step is done
129
+ function finalizeCompletedExecution(ctx: ExtensionContext): void {
130
+ if (!todoItems.every((t) => t.completed)) return
131
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
132
+ pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
133
+ executionMode = false
134
+ todoItems = []
135
+ restoreTools()
136
+ updateStatus(ctx)
137
+ persistState() // Save cleared state so resume doesn't restore old execution mode
138
+ }
139
+
140
+ // Fall back to extracting a plan from the last assistant message's prose
141
+ function deriveTodosFromProse(messages: AgentMessage[]): void {
142
+ const lastAssistant = [...messages].reverse().find(isAssistantMessage)
143
+ if (!lastAssistant) return
144
+ const extracted = extractTodoItems(getTextContent(lastAssistant))
145
+ if (extracted.length > 0) {
146
+ todoItems = extracted
147
+ }
148
+ }
149
+
150
+ // Ask the user how to proceed after a plan is ready
151
+ async function promptPlanNextAction(ctx: ExtensionContext): Promise<void> {
152
+ const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
153
+
154
+ if (choice?.startsWith('Execute')) {
155
+ planModeEnabled = false
156
+ executionMode = todoItems.length > 0
157
+ planFromTool = false
158
+ restoreTools()
159
+ updateStatus(ctx)
160
+
161
+ const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
162
+ pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
163
+ } else if (choice === 'Refine the plan') {
164
+ const refinement = await ctx.ui.editor('Refine the plan:', '')
165
+ if (refinement?.trim()) {
166
+ pi.sendUserMessage(refinement.trim())
167
+ }
168
+ }
169
+ }
170
+
171
+ // Rebuild completion state from assistant messages after the last execute marker
172
+ function rescanCompletion(entries: SessionEntry[]): void {
173
+ const executeIndex = findLastExecuteIndex(entries)
174
+ const messages: AssistantMessage[] = []
175
+ for (let i = executeIndex + 1; i < entries.length; i++) {
176
+ const entry = entries[i]
177
+ if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
178
+ messages.push(entry.message as AssistantMessage)
179
+ }
180
+ }
181
+ markCompletedSteps(messages.map(getTextContent).join('\n'), todoItems)
182
+ }
183
+
111
184
  pi.registerCommand('plan', {
112
185
  description: 'Toggle plan mode (read-only exploration)',
113
186
  handler: async (_args, ctx) => togglePlanMode(ctx),
@@ -193,7 +266,8 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
193
266
  Restrictions:
194
267
  - You can only use: read, bash, grep, find, ls, question
195
268
  - You CANNOT use: edit, write (file modifications are disabled)
196
- - Bash is restricted to an allowlist of read-only commands
269
+ - Bash is limited to an allowlist of read-only commands, checked per subcommand. Treat it
270
+ as a reminder of intent, not a sandbox: do not look for ways around it
197
271
 
198
272
  Ask clarifying questions using the question tool.
199
273
 
@@ -244,15 +318,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
244
318
  pi.on('agent_end', async (event, ctx) => {
245
319
  // Check if execution is complete
246
320
  if (executionMode && todoItems.length > 0) {
247
- if (todoItems.every((t) => t.completed)) {
248
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
249
- pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
250
- executionMode = false
251
- todoItems = []
252
- restoreTools()
253
- updateStatus(ctx)
254
- persistState() // Save cleared state so resume doesn't restore old execution mode
255
- }
321
+ finalizeCompletedExecution(ctx)
256
322
  return
257
323
  }
258
324
 
@@ -260,13 +326,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
260
326
 
261
327
  // Prefer an explicitly submitted plan; fall back to extracting from prose
262
328
  if (!planFromTool) {
263
- const lastAssistant = [...event.messages].reverse().find(isAssistantMessage)
264
- if (lastAssistant) {
265
- const extracted = extractTodoItems(getTextContent(lastAssistant))
266
- if (extracted.length > 0) {
267
- todoItems = extracted
268
- }
269
- }
329
+ deriveTodosFromProse(event.messages)
270
330
  }
271
331
 
272
332
  // Show plan steps and prompt for next action
@@ -282,23 +342,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
282
342
  )
283
343
  }
284
344
 
285
- const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
286
-
287
- if (choice?.startsWith('Execute')) {
288
- planModeEnabled = false
289
- executionMode = todoItems.length > 0
290
- planFromTool = false
291
- restoreTools()
292
- updateStatus(ctx)
293
-
294
- const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
295
- pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
296
- } else if (choice === 'Refine the plan') {
297
- const refinement = await ctx.ui.editor('Refine the plan:', '')
298
- if (refinement?.trim()) {
299
- pi.sendUserMessage(refinement.trim())
300
- }
301
- }
345
+ await promptPlanNextAction(ctx)
302
346
  })
303
347
 
304
348
  // Restore state on session start/resume
@@ -310,7 +354,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
310
354
  const entries = ctx.sessionManager.getEntries()
311
355
 
312
356
  // Restore persisted state
313
- const planModeEntry = entries.filter((e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode').pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
357
+ const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
314
358
 
315
359
  if (planModeEntry?.data) {
316
360
  planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
@@ -318,30 +362,11 @@ After completing a step, include a [DONE:n] tag in your response.`,
318
362
  executionMode = planModeEntry.data.executing ?? executionMode
319
363
  }
320
364
 
321
- // On resume: re-scan messages to rebuild completion state
322
- // Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
365
+ // On resume: re-scan messages after the last "plan-mode-execute" to rebuild
366
+ // completion state without picking up [DONE:n] from previous plans
323
367
  const isResume = planModeEntry !== undefined
324
368
  if (isResume && executionMode && todoItems.length > 0) {
325
- // Find the index of the last plan-mode-execute entry (marks when current execution started)
326
- let executeIndex = -1
327
- for (let i = entries.length - 1; i >= 0; i--) {
328
- const entry = entries[i] as { type: string; customType?: string }
329
- if (entry.customType === 'plan-mode-execute') {
330
- executeIndex = i
331
- break
332
- }
333
- }
334
-
335
- // Only scan messages after the execute marker
336
- const messages: AssistantMessage[] = []
337
- for (let i = executeIndex + 1; i < entries.length; i++) {
338
- const entry = entries[i]
339
- if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
340
- messages.push(entry.message as AssistantMessage)
341
- }
342
- }
343
- const allText = messages.map(getTextContent).join('\n')
344
- markCompletedSteps(allText, todoItems)
369
+ rescanCompletion(entries)
345
370
  }
346
371
 
347
372
  if (planModeEnabled) {