pi-code 0.3.1 → 0.4.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
@@ -38,10 +38,10 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
38
38
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
39
39
  | Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
40
40
  | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
- | Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
41
+ | Hooks | `.claude/settings.json` hooks: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit (blocks and injects context), Stop, PreCompact, SessionEnd | `hooks.ts` |
42
42
  | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
43
43
  | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
44
- | MCP servers | user `~/.claude.json`, `~/.pi/agent/mcp.json` (loaded on session start); project `.mcp.json`, `.pi/mcp.json` (only once the project is approved); stdio, HTTP, SSE | `mcp.ts` |
44
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
45
45
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
46
  | Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
47
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
@@ -49,7 +49,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
49
49
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
50
  | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
51
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
- | AskUserQuestion | vendored example | `question.ts` |
52
+ | AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
53
53
  | Statusline | turn state + session cost | `status-line.ts` |
54
54
  | Notifications | vendored example | `notify.ts` |
55
55
 
@@ -106,18 +106,29 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
106
106
  const createdAt = new Date().toISOString()
107
107
  const add = await gitShadow(['add', '-A'])
108
108
  if (add.code !== 0) return undefined
109
- const commit = await gitShadow(['commit', '-m', 'checkpoint'])
110
- if (commit.code !== 0) {
111
- // nothing changed since the last snapshot: reuse HEAD, or create the first empty commit
109
+ // Decide "nothing changed" from the index, not from the commit exit code: a commit can
110
+ // also fail on the user's global signing or hooks config, and reusing HEAD then would
111
+ // record a ref that predates the current tree, so /rewind restores the wrong state.
112
+ const status = await gitShadow(['status', '--porcelain'])
113
+ const nothingChanged = status.code === 0 && status.stdout.trim() === ''
114
+ if (nothingChanged) {
112
115
  const head = await gitShadow(['rev-parse', 'HEAD'])
113
116
  if (head.code === 0) return { ref: head.stdout.trim(), createdAt }
114
- const empty = await gitShadow(['commit', '--allow-empty', '-m', 'checkpoint'])
117
+ const empty = await commitShadow(['--allow-empty'])
115
118
  if (empty.code !== 0) return undefined
119
+ } else {
120
+ const commit = await commitShadow([])
121
+ if (commit.code !== 0) return undefined // real failure: do not record a stale ref
116
122
  }
117
123
  const sha = await gitShadow(['rev-parse', 'HEAD'])
118
124
  return sha.code === 0 ? { ref: sha.stdout.trim(), createdAt } : undefined
119
125
  }
120
126
 
127
+ /** Commit in the shadow repo, isolated from the user's global signing and hook config. */
128
+ function commitShadow(extra: string[]): ReturnType<ExtensionAPI['exec']> {
129
+ return gitShadow(['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', ...extra, '-m', 'checkpoint'])
130
+ }
131
+
121
132
  async function restoreCode(ctx: ExtensionCommandContext, checkpoint: Checkpoint): Promise<boolean> {
122
133
  if (!checkpoint.ref) {
123
134
  ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
5
  * a project's existing hooks work under pi:
6
- * - PreToolUse -> pi `tool_call` (can block the tool)
7
- * - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
8
- * - SessionStart-> pi `session_start` (fire-and-forget)
6
+ * - PreToolUse -> pi `tool_call` (can block the tool)
7
+ * - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
8
+ * - SessionStart -> pi `session_start` (fire-and-forget)
9
+ * - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
10
+ * additional context by transforming the submitted text)
11
+ * - Stop -> pi `agent_end` (fire-and-forget; cannot prevent stopping)
12
+ * - PreCompact -> pi `session_before_compact` (fire-and-forget)
13
+ * - SessionEnd -> pi `session_shutdown` (fire-and-forget)
14
+ *
15
+ * Claude's SubagentStop has no pi lifecycle seam (the subagent tool spawns child pi
16
+ * processes, and pi emits no subagent-completion event), so it is not bridged.
9
17
  *
10
18
  * Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
11
19
  * hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
@@ -102,7 +110,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, name: stri
102
110
  return result
103
111
  }
104
112
 
105
- function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
113
+ function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
106
114
  try {
107
115
  return JSON.parse(text)
108
116
  } catch {
@@ -115,8 +123,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
115
123
  if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
116
124
  const parsed = tryParseJson(stdout)
117
125
  const specific = parsed?.hookSpecificOutput
118
- if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
126
+ // pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
127
+ // rather than a silent allow, which is the least-safe reading on a trust-gated path.
128
+ if (specific?.permissionDecision === 'deny' || specific?.permissionDecision === 'ask') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
119
129
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
130
+ if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
120
131
  return { block: false }
121
132
  }
122
133
 
@@ -214,6 +225,35 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
214
225
  await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
215
226
  }
216
227
 
228
+ export interface PromptDecision {
229
+ block: boolean
230
+ reason?: string
231
+ context: string
232
+ }
233
+
234
+ /** Additional context a UserPromptSubmit hook contributes: an explicit
235
+ * hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
236
+ function promptContext(stdout: string): string {
237
+ const parsed = tryParseJson(stdout)
238
+ if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
239
+ return stdout.trim()
240
+ }
241
+
242
+ /** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
243
+ * additional context is concatenated for injection ahead of the prompt. */
244
+ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner): Promise<PromptDecision> {
245
+ const contexts: string[] = []
246
+ for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
247
+ const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
248
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
249
+ const decision = interpretHookResult(result.code, result.stdout, result.stderr)
250
+ if (decision.block) return { block: true, reason: decision.reason, context: '' }
251
+ const context = promptContext(result.stdout)
252
+ if (context) contexts.push(context)
253
+ }
254
+ return { block: false, context: contexts.join('\n') }
255
+ }
256
+
217
257
  /** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
218
258
  const MAX_PENDING_INPUTS = 100
219
259
 
@@ -255,4 +295,34 @@ export default function hooksExtension(pi: ExtensionAPI) {
255
295
  if (event.isError) return
256
296
  await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
257
297
  })
298
+
299
+ pi.on('input', async (event, ctx) => {
300
+ // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
301
+ // prompts the user submitted.
302
+ if (event.source === 'extension') return { action: 'continue' }
303
+ const decision = await runUserPromptSubmit(config, event.text, runner)
304
+ if (decision.block) {
305
+ // pi's input result has no reason channel, so surface why before consuming it.
306
+ ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
307
+ return { action: 'handled' }
308
+ }
309
+ // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
310
+ // pi's seam for rewriting the submitted text.
311
+ if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
312
+ return { action: 'continue' }
313
+ })
314
+
315
+ // Notify-style Claude events with a matching pi lifecycle seam. None can block: pi's
316
+ // agent_end, session_before_compact and session_shutdown are fire-and-forget here.
317
+ pi.on('agent_end', async () => {
318
+ await runNotifyHooks(matchingCommands(config.Stop, 'Stop'), { hook_event_name: 'Stop' }, runner)
319
+ })
320
+
321
+ pi.on('session_before_compact', async (event) => {
322
+ await runNotifyHooks(matchingCommands(config.PreCompact, event.reason), { hook_event_name: 'PreCompact', trigger: event.reason }, runner)
323
+ })
324
+
325
+ pi.on('session_shutdown', async (event) => {
326
+ await runNotifyHooks(matchingCommands(config.SessionEnd, event.reason), { hook_event_name: 'SessionEnd', reason: event.reason }, runner)
327
+ })
258
328
  }
@@ -21,6 +21,9 @@ export interface TransportOptions {
21
21
  userAgent: string
22
22
  }
23
23
 
24
+ /** Statuses the WHATWG Response constructor forbids a body on (per the fetch spec). */
25
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])
26
+
24
27
  /** One request, no redirect following (the caller re-validates and re-pins per hop). */
25
28
  export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
26
29
  const request = url.protocol === 'https:' ? httpsRequest : httpRequest
@@ -36,13 +39,23 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
36
39
  // validation use the real host even though the socket connects to the pinned IP.
37
40
  },
38
41
  (res) => {
39
- const headers = new Headers()
40
- for (const [key, value] of Object.entries(res.headers)) {
41
- if (typeof value === 'string') headers.set(key, value)
42
- else if (Array.isArray(value)) headers.set(key, value.join(', '))
42
+ try {
43
+ const headers = new Headers()
44
+ for (const [key, value] of Object.entries(res.headers)) {
45
+ if (typeof value === 'string') headers.set(key, value)
46
+ else if (Array.isArray(value)) headers.set(key, value.join(', '))
47
+ }
48
+ const status = res.statusCode ?? 0
49
+ // The Response constructor throws for a non-null body on a null-body status
50
+ // (204/205/304) and for status 0. That throw fires here, off the Promise
51
+ // executor, so without this guard it escapes as an uncaughtException and pi
52
+ // exits. Give those statuses a null body; reject anything else that throws.
53
+ const body = NULL_BODY_STATUSES.has(status) ? null : (Readable.toWeb(res) as ReadableStream<Uint8Array>)
54
+ resolve(new Response(body, { status, headers }))
55
+ } catch (err) {
56
+ res.resume() // drain so the socket can close
57
+ reject(err)
43
58
  }
44
- const body = Readable.toWeb(res) as ReadableStream<Uint8Array>
45
- resolve(new Response(body, { status: res.statusCode ?? 0, headers }))
46
59
  },
47
60
  )
48
61
  req.on('error', reject)
package/extensions/mcp.ts CHANGED
@@ -7,14 +7,15 @@
7
7
  * failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
8
8
  * transports; /mcp shows status.
9
9
  *
10
- * Reads Claude Code's MCP config too. User config (~/.claude.json,
11
- * ~/.pi/agent/mcp.json) is the user's own and loads on the first session. Project
12
- * config (.mcp.json, .pi/mcp.json) can run arbitrary commands on connect, so it
13
- * loads only once the project is approved (see project-approval). The two scopes
14
- * are loaded separately, not merged; user config connects first, so a project
15
- * server cannot take the name of a user server that connected.
16
- * Values support ${VAR} interpolation, and a stdio server receives only the SDK's
17
- * default environment plus its own `env` block, not the whole process environment.
10
+ * Reads Claude Code's MCP config too. User config (~/.claude.json top-level plus its
11
+ * per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
12
+ * user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
13
+ * can run arbitrary commands on connect, so it loads only once the project is approved
14
+ * (see project-approval). The two scopes are loaded separately, not merged; user config
15
+ * connects first, so a project server cannot take the name of a user server that connected.
16
+ * Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
17
+ * honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
18
+ * environment plus its own `env` block, not the whole process environment.
18
19
  */
19
20
 
20
21
  import * as fs from 'node:fs'
@@ -31,8 +32,20 @@ import { Type } from 'typebox'
31
32
  import { capForContext } from './internal/output-guard.js'
32
33
  import { isProjectApproved } from './internal/project-approval.js'
33
34
 
34
- const CONNECT_TIMEOUT_MS = 10_000
35
- const CALL_TIMEOUT_MS = 120_000
35
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
36
+ const DEFAULT_CALL_TIMEOUT_MS = 120_000
37
+
38
+ /** A positive-integer env override, or the default when unset or unparseable. */
39
+ function envTimeout(name: string, fallback: number): number {
40
+ const raw = process.env[name]
41
+ if (raw === undefined) return fallback
42
+ const value = Number.parseInt(raw, 10)
43
+ return Number.isInteger(value) && value > 0 ? value : fallback
44
+ }
45
+
46
+ // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
47
+ const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
48
+ const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
36
49
  // Tool names an MCP server must never take over. formatToolName always emits
37
50
  // `<server>_<tool>`, so only names containing an underscore are actually reachable:
38
51
  // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
@@ -91,6 +104,23 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
91
104
  return servers
92
105
  }
93
106
 
107
+ /**
108
+ * All user-owned servers for this session: the global user servers plus Claude's "local"
109
+ * scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
110
+ * Both are the user's own config, so neither needs project trust; local wins on a name
111
+ * clash (Claude's precedence is local over user).
112
+ */
113
+ export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
114
+ const servers = loadConfigFrom(userConfigPaths(home))
115
+ try {
116
+ const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf-8'))
117
+ Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
118
+ } catch {
119
+ // missing or invalid ~/.claude.json: the top-level user servers already loaded
120
+ }
121
+ return servers
122
+ }
123
+
94
124
  export function formatToolName(server: string, tool: string): string {
95
125
  return `${server}_${tool}`.replaceAll('-', '_')
96
126
  }
@@ -113,20 +143,24 @@ interface McpContentBlock {
113
143
  export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
114
144
 
115
145
  export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
146
+ // capForContext every text output, whatever its source: a server can blow the tool-output
147
+ // budget through a resource block, a JSON-stringified block, or the structured fallback,
148
+ // not only a text block.
149
+ const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
116
150
  if (!content || content.length === 0) {
117
- return [{ type: 'text', text: structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)' }]
151
+ return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
118
152
  }
119
153
  return content.map((block) => {
120
154
  if (block.type === 'text') {
121
- return { type: 'text', text: capForContext(block.text ?? '') }
155
+ return text(block.text ?? '')
122
156
  }
123
157
  if (block.type === 'image' && block.data) {
124
158
  return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
125
159
  }
126
160
  if (block.type === 'resource' && block.resource) {
127
- return { type: 'text', text: `[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}` }
161
+ return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
128
162
  }
129
- return { type: 'text', text: JSON.stringify(block) }
163
+ return text(JSON.stringify(block))
130
164
  })
131
165
  }
132
166
 
@@ -165,33 +199,53 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
165
199
  cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
166
200
  stderr: 'ignore',
167
201
  })
168
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
202
+ await connectWithTimeout(client, transport, `connect ${name}`)
169
203
  return client
170
204
  }
171
205
  const headers: Record<string, string> = {}
172
206
  for (const [key, value] of Object.entries(config.headers ?? {})) headers[key] = interpolateEnv(value)
173
- const token = config.bearerToken ?? (config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined)
207
+ const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
174
208
  if (token) headers.Authorization = `Bearer ${token}`
175
209
  const url = new URL(interpolateEnv(config.url))
176
210
  if (config.type === 'sse') {
177
211
  const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
178
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
212
+ await connectWithTimeout(client, transport, `connect ${name} (sse)`)
179
213
  return client
180
214
  }
181
215
  try {
182
216
  const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
183
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
217
+ await connectWithTimeout(client, transport, `connect ${name}`)
184
218
  return client
185
219
  } catch (error) {
186
220
  // An explicitly declared streamable transport must not silently degrade to SSE.
187
221
  if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
188
222
  const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
189
223
  const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
190
- await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
224
+ await connectWithTimeout(fallback, transport, `connect ${name} (sse)`)
191
225
  return fallback
192
226
  }
193
227
  }
194
228
 
229
+ /**
230
+ * Connect with a deadline, closing the client if the deadline (not a connect error) wins.
231
+ * Without this, a slow-but-successful server finishes connecting after the race is lost and
232
+ * lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
233
+ */
234
+ async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
235
+ const connecting = client.connect(transport)
236
+ try {
237
+ await withTimeout(connecting, connectTimeoutMs(), label)
238
+ } catch (error) {
239
+ // Only a timeout can orphan a still-opening transport; a connect rejection means the
240
+ // SDK already tore it down, so closing again would be redundant.
241
+ if (String(error).includes('timed out after')) {
242
+ connecting.catch(() => {}) // a late rejection must not surface as unhandled
243
+ void client.close().catch(() => {})
244
+ }
245
+ throw error
246
+ }
247
+ }
248
+
195
249
  async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
196
250
  const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
197
251
  let cursor: string | undefined
@@ -220,7 +274,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
220
274
  try {
221
275
  const client = await connect(name, config)
222
276
  clients.set(name, client)
223
- const tools = await withTimeout(listAllTools(client), CONNECT_TIMEOUT_MS, `list tools ${name}`)
277
+ const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
224
278
  let count = 0
225
279
  for (const tool of tools) {
226
280
  const toolName = formatToolName(name, tool.name)
@@ -236,7 +290,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
236
290
  description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
237
291
  parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
238
292
  async execute(_id, params) {
239
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }), CALL_TIMEOUT_MS, toolName)
293
+ // Pass the timeout to the SDK too: its own default request timeout is 60s and
294
+ // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
295
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
240
296
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
241
297
  const details: { error?: string } = {}
242
298
  if (result.isError) {
@@ -263,7 +319,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
263
319
  // the factory: pi runs the factory for invocations that never start a session.
264
320
  if (!userConnected) {
265
321
  userConnected = true
266
- await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
322
+ await connectServers(loadUserScope(os.homedir(), ctx.cwd))
267
323
  }
268
324
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
269
325
  // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
@@ -114,6 +114,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
114
114
  restoreTools()
115
115
  ctx.ui.notify('Plan mode disabled. Full access restored.')
116
116
  }
117
+ // Persist the toggle so a resume does not restore a state the user left.
118
+ persistState()
117
119
  updateStatus(ctx)
118
120
  }
119
121
 
@@ -158,6 +160,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
158
160
  restoreTools()
159
161
  updateStatus(ctx)
160
162
 
163
+ // Persist before the turn: a crash before the first turn_end must resume into
164
+ // execution, not back into plan mode.
165
+ persistState()
161
166
  const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
162
167
  pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
163
168
  } else if (choice === 'Refine the plan') {
@@ -351,6 +356,13 @@ After completing a step, include a [DONE:n] tag in your response.`,
351
356
 
352
357
  // Restore state on session start/resume
353
358
  pi.on('session_start', async (_event, ctx) => {
359
+ // One extension instance serves every session, so clear prior state first: a fresh
360
+ // session (/new, no plan entry) must not inherit the last session's plan or execution.
361
+ planModeEnabled = false
362
+ executionMode = false
363
+ todoItems = []
364
+ planFromTool = false
365
+
354
366
  if (pi.getFlag('plan') === true) {
355
367
  planModeEnabled = true
356
368
  }
@@ -375,6 +387,10 @@ After completing a step, include a [DONE:n] tag in your response.`,
375
387
 
376
388
  if (planModeEnabled) {
377
389
  enterPlanTools()
390
+ } else {
391
+ // A prior session in this instance may have shrunk the tool set; undo that when
392
+ // the restored/fresh state is not plan mode.
393
+ restoreTools()
378
394
  }
379
395
  updateStatus(ctx)
380
396
  })
@@ -191,9 +191,11 @@ export function cleanStepText(text: string): string {
191
191
  return cleaned
192
192
  }
193
193
 
194
- // Horizontal whitespace before the newline: \s would include \n itself and overlap
195
- // the following \n, which is what backtracks super-linearly.
196
- const PLAN_HEADER = /\*{0,2}Plan:\*{0,2}[^\S\n]*\n/i
194
+ // Anchored to line start (m flag) so a prose line merely ending in "plan:" is not taken
195
+ // for the header, which would slice the plan section mid-list and drop earlier steps.
196
+ // Horizontal whitespace only ([^\S\n]): \s would include \n itself and overlap the
197
+ // following \n, which is what backtracks super-linearly.
198
+ const PLAN_HEADER = /^[^\S\n]*\*{0,2}Plan:\*{0,2}[^\S\n]*\n/im
197
199
 
198
200
  const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
199
201
 
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Question Tool - Single question with options
3
- * Full custom UI: options list + inline editor for "Type something..."
4
- * Escape in editor returns to options, Escape in options cancels
2
+ * Question Tool - a question with options, single- or multi-select.
3
+ * Full custom UI: options list + inline editor for "Type something..." (single-select
4
+ * only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
5
+ * labels the question. Escape in the editor returns to options; Escape in options cancels.
6
+ * Multiple questions per call are not batched; ask sequentially.
5
7
  */
6
8
 
7
9
  import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
@@ -17,9 +19,11 @@ type DisplayOption = OptionWithDesc & { isOther?: boolean }
17
19
 
18
20
  interface QuestionDetails {
19
21
  question: string
22
+ header?: string
20
23
  options: string[]
21
24
  answer: string | null
22
25
  wasCustom?: boolean
26
+ multiSelect?: boolean
23
27
  }
24
28
 
25
29
  // Options with labels and optional descriptions
@@ -30,11 +34,18 @@ const OptionSchema = Type.Object({
30
34
 
31
35
  const QuestionParams = Type.Object({
32
36
  question: Type.String({ description: 'The question to ask the user' }),
37
+ header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it' })),
33
38
  options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
39
+ multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
34
40
  })
35
41
 
36
- function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, theme: Theme): string {
37
- const label = `${index + 1}. ${opt.label}`
42
+ function checkbox(checked: boolean | undefined): string {
43
+ if (checked === undefined) return ''
44
+ return checked ? '[x] ' : '[ ] '
45
+ }
46
+
47
+ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, checked: boolean | undefined, theme: Theme): string {
48
+ const label = `${index + 1}. ${checkbox(checked)}${opt.label}`
38
49
  const prefix = selected ? theme.fg('accent', '> ') : ' '
39
50
  if (opt.isOther === true && editMode) {
40
51
  return prefix + theme.fg('accent', `${label} ✎`)
@@ -48,25 +59,30 @@ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMo
48
59
  interface QuestionView {
49
60
  width: number
50
61
  question: string
62
+ header?: string
51
63
  options: DisplayOption[]
52
64
  optionIndex: number
53
65
  editMode: boolean
66
+ multiSelect: boolean
67
+ checked: boolean[]
54
68
  editor: Editor
55
69
  theme: Theme
56
70
  }
57
71
 
58
72
  function buildQuestionLines(view: QuestionView): string[] {
59
- const { width, question, options, optionIndex, editMode, editor, theme } = view
73
+ const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
60
74
  const lines: string[] = []
61
75
  const add = (s: string) => lines.push(truncateToWidth(s, width))
62
76
 
63
77
  add(theme.fg('accent', '─'.repeat(width)))
78
+ if (header) add(theme.fg('muted', ` [${header}]`))
64
79
  add(theme.fg('text', ` ${question}`))
65
80
  lines.push('')
66
81
 
67
82
  for (let i = 0; i < options.length; i++) {
68
83
  const opt = options[i]
69
- add(optionLine(opt, i, i === optionIndex, editMode, theme))
84
+ const box = multiSelect && opt.isOther !== true ? checked[i] : undefined
85
+ add(optionLine(opt, i, i === optionIndex, editMode, box, theme))
70
86
  if (opt.description) {
71
87
  add(` ${theme.fg('muted', opt.description)}`)
72
88
  }
@@ -81,12 +97,26 @@ function buildQuestionLines(view: QuestionView): string[] {
81
97
  }
82
98
 
83
99
  lines.push('')
84
- add(theme.fg('dim', editMode ? ' Enter to submit • Esc to go back' : ' ↑↓ navigate • Enter to select • Esc to cancel'))
100
+ add(theme.fg('dim', navHint(editMode, multiSelect)))
85
101
  add(theme.fg('accent', '─'.repeat(width)))
86
102
 
87
103
  return lines
88
104
  }
89
105
 
106
+ function navHint(editMode: boolean, multiSelect: boolean): string {
107
+ if (editMode) return ' Enter to submit • Esc to go back'
108
+ if (multiSelect) return ' ↑↓ navigate • Space to toggle • Enter to confirm • Esc to cancel'
109
+ return ' ↑↓ navigate • Enter to select • Esc to cancel'
110
+ }
111
+
112
+ /** The comma-joined labels of the checked options, in order. */
113
+ function selectedLabels(options: DisplayOption[], checked: boolean[]): string {
114
+ return options
115
+ .filter((_, i) => checked[i])
116
+ .map((o) => o.label)
117
+ .join(', ')
118
+ }
119
+
90
120
  export default function question(pi: ExtensionAPI) {
91
121
  pi.registerTool({
92
122
  name: 'question',
@@ -113,11 +143,14 @@ export default function question(pi: ExtensionAPI) {
113
143
  }
114
144
  }
115
145
 
116
- const allOptions: DisplayOption[] = [...params.options, { label: 'Type something.', isOther: true }]
146
+ const multiSelect = params.multiSelect === true
147
+ // The free-text option does not compose with checkbox selection, so it is single-select only.
148
+ const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
117
149
 
118
150
  const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui, theme, _kb, done) => {
119
151
  let optionIndex = 0
120
152
  let editMode = false
153
+ const checked: boolean[] = allOptions.map(() => false)
121
154
  let cachedLines: string[] | undefined
122
155
  let cachedWidth: number | undefined
123
156
 
@@ -173,7 +206,17 @@ export default function question(pi: ExtensionAPI) {
173
206
  return
174
207
  }
175
208
 
209
+ if (multiSelect && data === ' ') {
210
+ checked[optionIndex] = !checked[optionIndex]
211
+ refresh()
212
+ return
213
+ }
214
+
176
215
  if (matchesKey(data, Key.enter)) {
216
+ if (multiSelect) {
217
+ done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
218
+ return
219
+ }
177
220
  const selected = allOptions[optionIndex]
178
221
  if (selected.isOther) {
179
222
  editMode = true
@@ -192,7 +235,7 @@ export default function question(pi: ExtensionAPI) {
192
235
  function render(width: number): string[] {
193
236
  if (cachedLines && cachedWidth === width) return cachedLines
194
237
  cachedWidth = width
195
- cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
238
+ cachedLines = buildQuestionLines({ width, question: params.question, header: params.header, options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
196
239
  return cachedLines
197
240
  }
198
241
 
@@ -206,45 +249,41 @@ export default function question(pi: ExtensionAPI) {
206
249
  }
207
250
  })
208
251
 
209
- // Build simple options list for details
252
+ // Build simple options list for details; header/multiSelect appear only when set,
253
+ // so single-select details are unchanged.
210
254
  const simpleOptions = params.options.map((o) => o.label)
255
+ const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: params.header } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
211
256
 
212
257
  if (!result) {
213
258
  return {
214
259
  content: [{ type: 'text', text: 'User cancelled the selection' }],
215
- details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
260
+ details: { ...base, answer: null } as QuestionDetails,
216
261
  }
217
262
  }
218
263
 
219
264
  if (result.wasCustom) {
220
265
  return {
221
266
  content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
222
- details: {
223
- question: params.question,
224
- options: simpleOptions,
225
- answer: result.answer,
226
- wasCustom: true,
227
- } as QuestionDetails,
267
+ details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
228
268
  }
229
269
  }
270
+ const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
230
271
  return {
231
- content: [{ type: 'text', text: `User selected: ${result.index}. ${result.answer}` }],
232
- details: {
233
- question: params.question,
234
- options: simpleOptions,
235
- answer: result.answer,
236
- wasCustom: false,
237
- } as QuestionDetails,
272
+ content: [{ type: 'text', text: selectionText }],
273
+ details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
238
274
  }
239
275
  },
240
276
 
241
277
  renderCall(args, theme, _context) {
242
- let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', args.question)
278
+ const multi = args.multiSelect === true
279
+ const heading = args.header ? `[${args.header}] ` : ''
280
+ let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
243
281
  const opts = Array.isArray(args.options) ? args.options : []
244
282
  if (opts.length) {
245
283
  const labels = opts.map((o: OptionWithDesc) => o.label)
246
- const numbered = [...labels, 'Type something.'].map((o, i) => `${i + 1}. ${o}`)
247
- const optionsLine = ` Options: ${numbered.join(', ')}`
284
+ const shown = multi ? labels : [...labels, 'Type something.']
285
+ const numbered = shown.map((o, i) => `${i + 1}. ${o}`)
286
+ const optionsLine = ` Options${multi ? ' (multi)' : ''}: ${numbered.join(', ')}`
248
287
  text += `\n${theme.fg('dim', optionsLine)}`
249
288
  }
250
289
  return new Text(text, 0, 0)
@@ -264,6 +303,9 @@ export default function question(pi: ExtensionAPI) {
264
303
  if (details.wasCustom) {
265
304
  return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
266
305
  }
306
+ if (details.multiSelect) {
307
+ return new Text(theme.fg('success', '✓ ') + theme.fg('accent', details.answer || '(none)'), 0, 0)
308
+ }
267
309
  const idx = details.options.indexOf(details.answer) + 1
268
310
  const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer
269
311
  return new Text(theme.fg('success', '✓ ') + theme.fg('accent', display), 0, 0)
@@ -40,6 +40,8 @@ export default function statusLine(pi: ExtensionAPI) {
40
40
  }
41
41
 
42
42
  pi.on('session_start', async (_event, ctx) => {
43
+ // One instance serves every session, so a fresh session must not inherit the count.
44
+ turnCount = 0
43
45
  showIdle(ctx, ctx.ui.theme.fg('dim', '○'))
44
46
  })
45
47
 
@@ -47,9 +47,10 @@ export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns:
47
47
  }
48
48
  if (event.type !== 'message_end' || event.message?.role !== 'assistant') continue
49
49
  turns++
50
- for (const part of event.message.content ?? []) {
51
- if (part.type === 'text' && part.text) text = part.text
52
- }
50
+ // The complete text of the last assistant message, matching getFinalOutput on the
51
+ // foreground path so a multi-part message reads the same in both.
52
+ const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
53
+ if (parts.length > 0) text = parts.join('\n')
53
54
  }
54
55
  return { text, turns }
55
56
  }
@@ -83,6 +84,13 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
83
84
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
84
85
  })
85
86
  let stdout = ''
87
+ // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
88
+ let completed = false
89
+ const complete = (): void => {
90
+ if (completed) return
91
+ completed = true
92
+ onComplete(run)
93
+ }
86
94
  proc.stdout.on('data', (data) => {
87
95
  stdout += data.toString()
88
96
  })
@@ -92,12 +100,12 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
92
100
  run.exitCode = code ?? 0
93
101
  run.output = text
94
102
  run.turns = turns
95
- onComplete(run)
103
+ complete()
96
104
  })
97
105
  proc.on('error', () => {
98
106
  run.state = 'failed'
99
107
  run.exitCode = 1
100
- onComplete(run)
108
+ complete()
101
109
  })
102
110
  return id
103
111
  }
@@ -160,9 +160,10 @@ export function getFinalOutput(messages: Message[]): string {
160
160
  for (let i = messages.length - 1; i >= 0; i--) {
161
161
  const msg = messages[i]
162
162
  if (msg.role === 'assistant') {
163
- for (const part of msg.content) {
164
- if (part.type === 'text') return part.text
165
- }
163
+ // The complete text of the last assistant message: a message can carry more than one
164
+ // text part, and taking only the first diverged from the background parser.
165
+ const parts = msg.content.filter((part) => part.type === 'text').map((part) => part.text)
166
+ if (parts.length > 0) return parts.join('\n')
166
167
  }
167
168
  }
168
169
  return ''
@@ -486,6 +487,10 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
486
487
  for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
487
488
  const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
488
489
 
490
+ // No project agents means nothing repo-controlled to gate; skip the approval check so a
491
+ // user-scope run never prompts or persists a trust decision it does not need.
492
+ if (requestedProjectAgents.length === 0) return null
493
+
489
494
  // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
490
495
  const approved = await isProjectApproved(ctx)
491
496
  const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
@@ -634,7 +639,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
634
639
  details: makeDetails('chain')(results),
635
640
  }
636
641
  }
637
- previousOutput = getFinalOutput(result.messages)
642
+ previousOutput = capForContext(getFinalOutput(result.messages))
638
643
  }
639
644
  return {
640
645
  content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
@@ -692,8 +697,11 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
692
697
  signal,
693
698
  // Per-task update callback
694
699
  onUpdate: (partial) => {
695
- if (partial.details?.results[0]) {
696
- allResults[index] = partial.details.results[0]
700
+ const live = partial.details?.results[0]
701
+ if (live) {
702
+ // Keep the running sentinel until the child closes: the streamed result carries
703
+ // exitCode 0 mid-run, which would otherwise count and render the task as done.
704
+ allResults[index] = { ...live, exitCode: -1 }
697
705
  emitParallelUpdate()
698
706
  }
699
707
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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"