pi-code 1.0.53 → 1.0.55

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.
@@ -174,10 +174,19 @@ export function shellExecutionDisabled(cwd: string, home: string, trusted: boole
174
174
  if (readManagedSettings().disableSkillShellExecution === true) return true
175
175
  const files = claudeSettingsChain(cwd, home, trusted)
176
176
  return files.some((file) => {
177
+ let raw: string
177
178
  try {
178
- return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
179
+ raw = fs.readFileSync(file, 'utf-8')
179
180
  } catch {
180
- return false // missing or invalid file: not a policy statement
181
+ return false // no such file: genuinely not a policy statement
182
+ }
183
+ try {
184
+ return (JSON.parse(raw) as Record<string, unknown>).disableSkillShellExecution === true
185
+ } catch {
186
+ // Present but unreadable: the user may believe this file disables skill shells, so
187
+ // say so rather than silently leaving them enabled.
188
+ console.warn(`pi-code: ignoring ${file}: not valid JSON; skill shell execution stays as the other settings leave it`)
189
+ return false
181
190
  }
182
191
  })
183
192
  }
@@ -316,7 +325,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
316
325
  // escape as unhandled; surface it as a no-op instead of leaving the session silently
317
326
  // on the command's override model.
318
327
  set: (model) => {
319
- void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch(() => {})
328
+ // A refused switch leaves the turn on the session model rather than the one the
329
+ // command named, and the reply gives no sign of it, so the refusal is reported.
330
+ void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
331
+ console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${error instanceof Error ? error.message : String(error)}`)
332
+ })
320
333
  },
321
334
  })
322
335
  /** The thinking level to restore after a command's `effort:` override drove its run,
@@ -496,8 +509,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
496
509
  let parsed: ParsedCommand
497
510
  try {
498
511
  parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
499
- } catch {
500
- continue // an unreadable command file must not take down session start
512
+ } catch (error) {
513
+ // An unreadable file must not take down session start, but the command is then
514
+ // absent from /help and unresolvable by the model, which looks like one that was
515
+ // never written.
516
+ console.warn(`pi-code-commands: ignoring ${command.filePath}: ${error instanceof Error ? error.message : String(error)}`)
517
+ continue
501
518
  }
502
519
  discovered.set(command.name, command)
503
520
  // A user-only command stays off the tool description; it is still in the
@@ -210,7 +210,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
210
210
  }
211
211
  // Written on every start, so repos that predate the sidecar pick it up too.
212
212
  rememberWorkTree(shadowDir, ctx.cwd)
213
- await mirrorLocalExcludes(ctx.cwd)
213
+ await mirrorLocalExcludes(ctx)
214
214
  }
215
215
 
216
216
  /** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
@@ -219,7 +219,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
219
219
  * would be snapshotted and restored. Mirror it into the shadow on every start; the
220
220
  * global excludes stay untouched (core.excludesFile is single-valued, so pointing it
221
221
  * at the repo file would replace them). */
222
- async function mirrorLocalExcludes(cwd: string): Promise<void> {
222
+ async function mirrorLocalExcludes(ctx: ExtensionContext): Promise<void> {
223
+ const cwd = ctx.cwd
223
224
  if (!shadowDir) return
224
225
  // Resolved through git so a linked worktree maps to its common dir; outside a repo
225
226
  // git exits 128 and there is nothing to mirror.
@@ -233,8 +234,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
233
234
  } else {
234
235
  fs.rmSync(target, { force: true })
235
236
  }
236
- } catch {
237
- // A failed mirror only means local excludes are not honored this session.
237
+ } catch (error) {
238
+ // Without the mirror, files the user excluded locally are snapshotted into the
239
+ // checkpoint store and restored by /rewind, so this is not a silent fallback.
240
+ ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${error instanceof Error ? error.message : String(error)}`, 'warning')
238
241
  }
239
242
  }
240
243
 
@@ -200,7 +200,10 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
200
200
  let parsed: { hooks?: HooksConfig }
201
201
  try {
202
202
  parsed = JSON.parse(raw)
203
- } catch {
203
+ } catch (error) {
204
+ // Every hook this source declares is now absent, a policy hook among them, so the
205
+ // failure is named rather than left to look like a file with no hooks in it.
206
+ console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${error instanceof Error ? error.message : String(error)}`)
204
207
  return
205
208
  }
206
209
  for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
@@ -59,8 +59,10 @@ function compileMatcher(matcher: string): CompiledMatcher {
59
59
  } else {
60
60
  try {
61
61
  compiled = { regex: new RegExp(matcher, 'i') }
62
- } catch {
63
- // An invalid regex matcher falls back to exact-name matching, as before.
62
+ } catch (error) {
63
+ // The fallback matches the literal text, which almost never matches a tool name, so
64
+ // the hook simply never fires. Say so: the matcher reads as merely wrong otherwise.
65
+ console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${error instanceof Error ? error.message : String(error)}); it will only match a tool of that exact name`)
64
66
  compiled = { tokens: exactTokens(matcher) }
65
67
  }
66
68
  }
@@ -31,10 +31,19 @@ export interface InstalledPlugin {
31
31
  }
32
32
 
33
33
  function readJson(file: string): Record<string, unknown> {
34
+ let raw: string
34
35
  try {
35
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
36
- return parsed !== null && typeof parsed === 'object' ? parsed : {}
36
+ raw = fs.readFileSync(file, 'utf-8')
37
37
  } catch {
38
+ return {} // no such file: nothing to read, and most callers expect that
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(raw)
42
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
43
+ } catch (error) {
44
+ // A manifest that does not parse leaves the plugin with no components at all, and
45
+ // settings that do not parse drop the enablement or configuration they carried.
46
+ console.warn(`pi-code-plugins: ignoring ${file}: ${error instanceof Error ? error.message : String(error)}`)
38
47
  return {}
39
48
  }
40
49
  }
@@ -28,6 +28,12 @@ function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError
28
28
  * (stored-token) connects do not pass through here and stay fully parallel. */
29
29
  let oauthQueue: Promise<unknown> = Promise.resolve()
30
30
 
31
+ /** Test seam: the queue is module state that outlives a test, so one login left pending
32
+ * would serialize every later login in the same file behind it. */
33
+ export function resetOAuthQueue(): void {
34
+ oauthQueue = Promise.resolve()
35
+ }
36
+
31
37
  export function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
32
38
  const result = oauthQueue.then(run, run)
33
39
  oauthQueue = result.then(
@@ -97,11 +97,19 @@ function parsePolicyEntry(entry: unknown): McpPolicyEntry | undefined {
97
97
  * honored scope sets the key; an empty list is an explicit lockdown (deny all). */
98
98
  export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = managedSettingsFile()): McpPolicy {
99
99
  const read = (file: string): Record<string, unknown> => {
100
+ let raw: string
100
101
  try {
101
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
102
+ raw = fs.readFileSync(file, 'utf-8')
103
+ } catch {
104
+ return {} // no such file: genuinely no policy
105
+ }
106
+ try {
107
+ const parsed = JSON.parse(raw)
102
108
  return parsed && typeof parsed === 'object' ? parsed : {}
103
109
  } catch {
104
- // Missing or invalid file contributes no policy.
110
+ // Present but unreadable: its allow and deny lists are not in force, and a deny list
111
+ // silently dropped leaves every server allowed.
112
+ console.warn(`pi-code-mcp: ignoring ${file}: not valid JSON; its MCP allow and deny lists are not applied`)
105
113
  return {}
106
114
  }
107
115
  }
@@ -336,15 +336,29 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
336
336
  * environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
337
337
  * or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
338
338
  * yields no extra headers rather than blocking the connection. */
339
- function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
339
+ export function runHeadersHelper(command: string, env: NodeJS.ProcessEnv, resolve_ = resolveShell): Promise<Record<string, string>> {
340
340
  return new Promise((resolve) => {
341
- const shell = resolveShell(undefined)
341
+ // The resolver is a parameter so a test can drive both outcomes on any platform:
342
+ // stubbing the platform cannot produce a shell-less host on a machine that has one.
343
+ const shell = resolve_(undefined)
342
344
  if (!shell) {
343
345
  resolve({})
344
346
  return
345
347
  }
348
+ const server = env.CLAUDE_CODE_MCP_SERVER_NAME ?? 'the server'
346
349
  execFile(shell.file, shell.argsFor(command), { timeout: 10_000, env }, (error, stdout) => {
347
- resolve(error ? {} : parseHelperHeaders(stdout))
350
+ if (error) {
351
+ // The connect proceeds unauthenticated and the server answers 401, which reads as
352
+ // a login problem rather than a helper that never produced a header.
353
+ console.warn(`pi-code-mcp: the headersHelper for ${server} failed: ${error.message}; connecting without the headers it would have supplied`)
354
+ resolve({})
355
+ return
356
+ }
357
+ const headers = parseHelperHeaders(stdout)
358
+ if (Object.keys(headers).length === 0 && stdout.trim().length > 0) {
359
+ console.warn(`pi-code-mcp: the headersHelper for ${server} produced no usable headers; it must print a JSON object of header names to string values`)
360
+ }
361
+ resolve(headers)
348
362
  })
349
363
  })
350
364
  }
@@ -149,8 +149,10 @@ export function migrateLegacyStore(cwd: string): void {
149
149
  if (legacy === current || !fs.existsSync(legacy)) continue
150
150
  try {
151
151
  fs.renameSync(legacy, current)
152
- } catch {
153
- // A failed migration must not take down session start; the store stays put.
152
+ } catch (error) {
153
+ // A failed migration must not take down session start, but the session then has no
154
+ // memories while they sit under the old slug, which reads as having lost them.
155
+ console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${error instanceof Error ? error.message : String(error)}; this session starts without those memories`)
154
156
  }
155
157
  return
156
158
  }
@@ -230,7 +232,12 @@ function readMemory(dir: string, name: string): MemoryToolResult {
230
232
  try {
231
233
  const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
232
234
  return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
233
- } catch {
235
+ } catch (error) {
236
+ // Only a missing file is "no such memory"; anything else (a directory in its place, a
237
+ // permission problem) sends the model hunting for a name that is actually there.
238
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
239
+ return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${error instanceof Error ? error.message : String(error)}` }], details: {} }
240
+ }
234
241
  return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
235
242
  }
236
243
  }
@@ -177,8 +177,11 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string,
177
177
  let parsed: { frontmatter: Record<string, unknown>; body: string }
178
178
  try {
179
179
  parsed = parseFrontmatter<Record<string, unknown>>(content)
180
- } catch {
181
- return null // malformed YAML must not abort discovery for the whole directory
180
+ } catch (error) {
181
+ // Malformed YAML must not abort discovery for the whole directory, but a silent drop
182
+ // reads as "that agent does not exist", so it is named like the other rejections here.
183
+ console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${error instanceof Error ? error.message : String(error)})`)
184
+ return null
182
185
  }
183
186
  const { frontmatter, body } = parsed
184
187
  const name = agentName(frontmatter, filePath, pluginName)
@@ -204,7 +204,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
204
204
  if (!fs.existsSync(run.spawn.cwd)) return 'cwd-gone'
205
205
  // Persisted so the rebuild happens once: rebuilding per resume leaked one temp
206
206
  // prompt dir every follow-up.
207
- const rebuilt = withRebuiltPrompt(run.spawn)
207
+ const rebuilt = withRebuiltPrompt(run.spawn, run.agent)
208
208
  run.spawn = { ...run.spawn, args: rebuilt.args }
209
209
  if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
210
210
  // The task prompt is always the final argument: both spawn paths push it last and the
@@ -228,7 +228,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
228
228
 
229
229
  /** Re-point --system-prompt at a fresh file when the original is gone; `dir` is the
230
230
  * temp dir created for it, which the run then owns. */
231
- function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?: string } {
231
+ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: string[]; dir?: string } {
232
232
  const flag = spawnSpec.args.indexOf('--system-prompt')
233
233
  if (flag === -1 || !spawnSpec.promptBody) return { args: spawnSpec.args }
234
234
  const current = spawnSpec.args[flag + 1]
@@ -240,9 +240,12 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?:
240
240
  const rebuilt = [...spawnSpec.args]
241
241
  rebuilt[flag + 1] = file
242
242
  return { args: rebuilt, dir }
243
- } catch {
243
+ } catch (error) {
244
244
  // Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
245
- // prompt text, which would replace the agent persona with a temp path.
245
+ // prompt text, which would replace the agent persona with a temp path. The child then
246
+ // runs as a plain assistant instead of the agent asked for, and nothing in its output
247
+ // says so, hence the notice.
248
+ console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${error instanceof Error ? error.message : String(error)}`)
246
249
  return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
247
250
  }
248
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",