pi-code 0.3.2 → 0.4.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
@@ -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
 
@@ -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; continue?: boolean; stopReason?: 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 {
@@ -217,6 +225,35 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
217
225
  await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
218
226
  }
219
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
+
220
257
  /** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
221
258
  const MAX_PENDING_INPUTS = 100
222
259
 
@@ -258,4 +295,34 @@ export default function hooksExtension(pi: ExtensionAPI) {
258
295
  if (event.isError) return
259
296
  await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
260
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
+ })
261
328
  }
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
  }
@@ -204,7 +234,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
204
234
  async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
205
235
  const connecting = client.connect(transport)
206
236
  try {
207
- await withTimeout(connecting, CONNECT_TIMEOUT_MS, label)
237
+ await withTimeout(connecting, connectTimeoutMs(), label)
208
238
  } catch (error) {
209
239
  // Only a timeout can orphan a still-opening transport; a connect rejection means the
210
240
  // SDK already tore it down, so closing again would be redundant.
@@ -244,7 +274,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
244
274
  try {
245
275
  const client = await connect(name, config)
246
276
  clients.set(name, client)
247
- const tools = await withTimeout(listAllTools(client), CONNECT_TIMEOUT_MS, `list tools ${name}`)
277
+ const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
248
278
  let count = 0
249
279
  for (const tool of tools) {
250
280
  const toolName = formatToolName(name, tool.name)
@@ -262,7 +292,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
262
292
  async execute(_id, params) {
263
293
  // Pass the timeout to the SDK too: its own default request timeout is 60s and
264
294
  // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
265
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: CALL_TIMEOUT_MS }), CALL_TIMEOUT_MS, toolName)
295
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
266
296
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
267
297
  const details: { error?: string } = {}
268
298
  if (result.isError) {
@@ -289,7 +319,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
289
319
  // the factory: pi runs the factory for invocations that never start a session.
290
320
  if (!userConnected) {
291
321
  userConnected = true
292
- await connectServers(loadConfigFrom(userConfigPaths(os.homedir())))
322
+ await connectServers(loadUserScope(os.homedir(), ctx.cwd))
293
323
  }
294
324
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
295
325
  // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
@@ -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)
@@ -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
  }
@@ -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 ''
@@ -638,7 +639,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
638
639
  details: makeDetails('chain')(results),
639
640
  }
640
641
  }
641
- previousOutput = getFinalOutput(result.messages)
642
+ previousOutput = capForContext(getFinalOutput(result.messages))
642
643
  }
643
644
  return {
644
645
  content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
package/package.json CHANGED
@@ -1,9 +1,23 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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
- "pi-package"
6
+ "pi",
7
+ "pi-package",
8
+ "pi-coding-agent",
9
+ "pi-extension",
10
+ "extension",
11
+ "claude",
12
+ "claude-code",
13
+ "coding-agent",
14
+ "ai",
15
+ "mcp",
16
+ "hooks",
17
+ "skills",
18
+ "subagents",
19
+ "memory",
20
+ "todo"
7
21
  ],
8
22
  "license": "MIT",
9
23
  "type": "module",