pi-code 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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')
@@ -102,7 +102,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, name: stri
102
102
  return result
103
103
  }
104
104
 
105
- function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string } | undefined {
105
+ function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
106
106
  try {
107
107
  return JSON.parse(text)
108
108
  } catch {
@@ -115,8 +115,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
115
115
  if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
116
116
  const parsed = tryParseJson(stdout)
117
117
  const specific = parsed?.hookSpecificOutput
118
- if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
118
+ // pi's tool_call return is allow-or-block, so "ask" (confirm) maps to block-with-reason
119
+ // rather than a silent allow, which is the least-safe reading on a trust-gated path.
120
+ if (specific?.permissionDecision === 'deny' || specific?.permissionDecision === 'ask') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
119
121
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
122
+ if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
120
123
  return { block: false }
121
124
  }
122
125
 
@@ -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
@@ -113,20 +113,24 @@ interface McpContentBlock {
113
113
  export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
114
114
 
115
115
  export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
116
+ // capForContext every text output, whatever its source: a server can blow the tool-output
117
+ // budget through a resource block, a JSON-stringified block, or the structured fallback,
118
+ // not only a text block.
119
+ const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
116
120
  if (!content || content.length === 0) {
117
- return [{ type: 'text', text: structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)' }]
121
+ return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
118
122
  }
119
123
  return content.map((block) => {
120
124
  if (block.type === 'text') {
121
- return { type: 'text', text: capForContext(block.text ?? '') }
125
+ return text(block.text ?? '')
122
126
  }
123
127
  if (block.type === 'image' && block.data) {
124
128
  return { type: 'image', data: block.data, mimeType: block.mimeType ?? 'image/png' }
125
129
  }
126
130
  if (block.type === 'resource' && block.resource) {
127
- return { type: 'text', text: `[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}` }
131
+ return text(`[Resource: ${block.resource.uri ?? 'unknown'}]\n${block.resource.text ?? ''}`)
128
132
  }
129
- return { type: 'text', text: JSON.stringify(block) }
133
+ return text(JSON.stringify(block))
130
134
  })
131
135
  }
132
136
 
@@ -165,33 +169,53 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
165
169
  cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
166
170
  stderr: 'ignore',
167
171
  })
168
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
172
+ await connectWithTimeout(client, transport, `connect ${name}`)
169
173
  return client
170
174
  }
171
175
  const headers: Record<string, string> = {}
172
176
  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)
177
+ const token = config.bearerToken ? interpolateEnv(config.bearerToken) : config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined
174
178
  if (token) headers.Authorization = `Bearer ${token}`
175
179
  const url = new URL(interpolateEnv(config.url))
176
180
  if (config.type === 'sse') {
177
181
  const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
178
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
182
+ await connectWithTimeout(client, transport, `connect ${name} (sse)`)
179
183
  return client
180
184
  }
181
185
  try {
182
186
  const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
183
- await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
187
+ await connectWithTimeout(client, transport, `connect ${name}`)
184
188
  return client
185
189
  } catch (error) {
186
190
  // An explicitly declared streamable transport must not silently degrade to SSE.
187
191
  if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
188
192
  const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
189
193
  const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
190
- await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
194
+ await connectWithTimeout(fallback, transport, `connect ${name} (sse)`)
191
195
  return fallback
192
196
  }
193
197
  }
194
198
 
199
+ /**
200
+ * Connect with a deadline, closing the client if the deadline (not a connect error) wins.
201
+ * Without this, a slow-but-successful server finishes connecting after the race is lost and
202
+ * lingers unreferenced: process/socket alive, never in `clients`, invisible to shutdown.
203
+ */
204
+ async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
205
+ const connecting = client.connect(transport)
206
+ try {
207
+ await withTimeout(connecting, CONNECT_TIMEOUT_MS, label)
208
+ } catch (error) {
209
+ // Only a timeout can orphan a still-opening transport; a connect rejection means the
210
+ // SDK already tore it down, so closing again would be redundant.
211
+ if (String(error).includes('timed out after')) {
212
+ connecting.catch(() => {}) // a late rejection must not surface as unhandled
213
+ void client.close().catch(() => {})
214
+ }
215
+ throw error
216
+ }
217
+ }
218
+
195
219
  async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
196
220
  const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
197
221
  let cursor: string | undefined
@@ -236,7 +260,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
236
260
  description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
237
261
  parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
238
262
  async execute(_id, params) {
239
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }), CALL_TIMEOUT_MS, toolName)
263
+ // Pass the timeout to the SDK too: its own default request timeout is 60s and
264
+ // 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)
240
266
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
241
267
  const details: { error?: string } = {}
242
268
  if (result.isError) {
@@ -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
 
@@ -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
 
@@ -83,6 +83,13 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
83
83
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
84
84
  })
85
85
  let stdout = ''
86
+ // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
87
+ let completed = false
88
+ const complete = (): void => {
89
+ if (completed) return
90
+ completed = true
91
+ onComplete(run)
92
+ }
86
93
  proc.stdout.on('data', (data) => {
87
94
  stdout += data.toString()
88
95
  })
@@ -92,12 +99,12 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
92
99
  run.exitCode = code ?? 0
93
100
  run.output = text
94
101
  run.turns = turns
95
- onComplete(run)
102
+ complete()
96
103
  })
97
104
  proc.on('error', () => {
98
105
  run.state = 'failed'
99
106
  run.exitCode = 1
100
- onComplete(run)
107
+ complete()
101
108
  })
102
109
  return id
103
110
  }
@@ -486,6 +486,10 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
486
486
  for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
487
487
  const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
488
488
 
489
+ // No project agents means nothing repo-controlled to gate; skip the approval check so a
490
+ // user-scope run never prompts or persists a trust decision it does not need.
491
+ if (requestedProjectAgents.length === 0) return null
492
+
489
493
  // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
490
494
  const approved = await isProjectApproved(ctx)
491
495
  const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
@@ -692,8 +696,11 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
692
696
  signal,
693
697
  // Per-task update callback
694
698
  onUpdate: (partial) => {
695
- if (partial.details?.results[0]) {
696
- allResults[index] = partial.details.results[0]
699
+ const live = partial.details?.results[0]
700
+ if (live) {
701
+ // Keep the running sentinel until the child closes: the streamed result carries
702
+ // exitCode 0 mid-run, which would otherwise count and render the task as done.
703
+ allResults[index] = { ...live, exitCode: -1 }
697
704
  emitParallelUpdate()
698
705
  }
699
706
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi-package"