pi-code 1.0.52 → 1.0.54

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.
@@ -43,10 +43,10 @@ import * as os from 'node:os'
43
43
  import * as path from 'node:path'
44
44
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
45
45
  import { Type } from 'typebox'
46
-
47
46
  import { matchesBashRules } from './internal/bash-rules.js'
48
47
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
49
48
  import { claudeConfigDir } from './internal/config-dir.js'
49
+ import { claudeEffortLevel } from './internal/effort.js'
50
50
  import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
51
51
  import { capForContext } from './internal/output-guard.js'
52
52
  import { matchesPathRules } from './internal/path-rules.js'
@@ -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
178
+ try {
179
+ raw = fs.readFileSync(file, 'utf-8')
180
+ } catch {
181
+ return false // no such file: genuinely not a policy statement
182
+ }
177
183
  try {
178
- return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
184
+ return (JSON.parse(raw) as Record<string, unknown>).disableSkillShellExecution === true
179
185
  } catch {
180
- return false // missing or invalid file: not a policy statement
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
  }
@@ -187,7 +196,9 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
187
196
  const varCtx = ctx as unknown as VarContext
188
197
  return {
189
198
  CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
190
- CLAUDE_EFFORT: varCtx.thinkingLevel,
199
+ // Claude vocabulary only, and unset when thinking is off: the variable promises
200
+ // low|medium|high|xhigh|max, and an unset one stays literal in the body.
201
+ CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
191
202
  CLAUDE_SKILL_DIR: path.dirname(filePath),
192
203
  CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
193
204
  CLAUDE_PLUGIN_ROOT: plugin?.root,
@@ -18,6 +18,7 @@ import * as fs from 'node:fs'
18
18
  import * as os from 'node:os'
19
19
  import * as path from 'node:path'
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
+ import { claudeConfigDir } from './internal/config-dir.js'
21
22
 
22
23
  const CUSTOM_TYPE = 'git-checkpoint'
23
24
  /** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
@@ -37,6 +38,21 @@ interface Checkpoint {
37
38
  * for the life of the machine. */
38
39
  export const CHECKPOINT_RETENTION_DAYS = 30
39
40
 
41
+ /** The retention period in effect: Claude keeps checkpoints for 30 days and says to
42
+ * "change the period with cleanupPeriodDays". Read from the user scope, which is where a
43
+ * setting about the user's own disk belongs; a non-positive or unreadable value keeps the
44
+ * default rather than sweeping everything away. */
45
+ export function checkpointRetentionDays(home: string = os.homedir()): number {
46
+ try {
47
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
48
+ const declared = (parsed as { cleanupPeriodDays?: unknown }).cleanupPeriodDays
49
+ if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
50
+ } catch {
51
+ // No user settings, or unreadable: the default period stands.
52
+ }
53
+ return CHECKPOINT_RETENTION_DAYS
54
+ }
55
+
40
56
  /** Claude keeps the 100 most recent checkpoints per session. Older ones drop off the
41
57
  * rewind list; their commits stay in the shadow repo until the retention sweep. */
42
58
  export const MAX_CHECKPOINTS_PER_SESSION = 100
@@ -179,7 +195,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
179
195
  shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
180
196
  ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
181
197
  }
182
- pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
198
+ pruneCheckpointRepos(checkpointsRoot, checkpointRetentionDays(os.homedir()), shadowDir)
183
199
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
184
200
  if (check.code !== 0) {
185
201
  const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
@@ -194,7 +210,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
194
210
  }
195
211
  // Written on every start, so repos that predate the sidecar pick it up too.
196
212
  rememberWorkTree(shadowDir, ctx.cwd)
197
- await mirrorLocalExcludes(ctx.cwd)
213
+ await mirrorLocalExcludes(ctx)
198
214
  }
199
215
 
200
216
  /** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
@@ -203,7 +219,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
203
219
  * would be snapshotted and restored. Mirror it into the shadow on every start; the
204
220
  * global excludes stay untouched (core.excludesFile is single-valued, so pointing it
205
221
  * at the repo file would replace them). */
206
- async function mirrorLocalExcludes(cwd: string): Promise<void> {
222
+ async function mirrorLocalExcludes(ctx: ExtensionContext): Promise<void> {
223
+ const cwd = ctx.cwd
207
224
  if (!shadowDir) return
208
225
  // Resolved through git so a linked worktree maps to its common dir; outside a repo
209
226
  // git exits 128 and there is nothing to mirror.
@@ -217,8 +234,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
217
234
  } else {
218
235
  fs.rmSync(target, { force: true })
219
236
  }
220
- } catch {
221
- // 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')
222
241
  }
223
242
  }
224
243
 
@@ -262,8 +262,6 @@ export interface PromptDecision {
262
262
  block: boolean
263
263
  reason?: string
264
264
  context: string
265
- /** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
266
- suppress?: boolean
267
265
  }
268
266
 
269
267
  /** Additional context a UserPromptSubmit hook contributes: an explicit
@@ -291,17 +289,13 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
291
289
  }
292
290
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
293
291
  const contexts: string[] = []
294
- let suppress = false
295
292
  for (const result of results) {
296
293
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
297
294
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
298
- // Claude's suppressOriginalPrompt: any hook setting it hides the original
299
- // prompt, and the collected context is what reaches the model.
300
- if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
301
295
  const context = promptContext(result.stdout)
302
296
  if (context) contexts.push(context)
303
297
  }
304
- return { block: false, context: contexts.join('\n'), suppress }
298
+ return { block: false, context: contexts.join('\n') }
305
299
  }
306
300
 
307
301
  /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
@@ -96,6 +96,7 @@
96
96
 
97
97
  import * as os from 'node:os'
98
98
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
99
+ import { claudeEffortLevel } from '../internal/effort.js'
99
100
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
100
101
  import { readManagedSettings } from '../internal/managed-settings.js'
101
102
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
@@ -186,6 +187,25 @@ const IDLE_PROMPT_DELAY_MS = 60_000
186
187
  * Claude Code restores on resume. */
187
188
  const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
188
189
 
190
+ /** One Stop hook result read as a verdict. Claude: `continue` "takes precedence over any
191
+ * event-specific decision fields", and stopReason is the message shown when it is false,
192
+ * so a hook asking to stop wins over its own block whatever exit code carried it. Any
193
+ * blocking spelling counts, including the prompt and agent hook reply schemas, and a
194
+ * non-error additionalContext feeds back the same way so the block cap still bounds it. */
195
+ function stopVerdict(result: HookRunResult, stopMessages: string[]): { block: boolean; reason: string } {
196
+ const parsed = tryParseJson(result.stdout)
197
+ if (parsed?.continue === false) {
198
+ if (parsed.stopReason) stopMessages.push(String(parsed.stopReason))
199
+ return { block: false, reason: '' }
200
+ }
201
+ if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
202
+ const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
203
+ if (verdict) return { block: true, reason: verdict.reason }
204
+ const context = parsed?.hookSpecificOutput?.additionalContext
205
+ if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
206
+ return { block: false, reason: '' }
207
+ }
208
+
189
209
  export default function hooksExtension(pi: ExtensionAPI) {
190
210
  let config: HooksConfig = {}
191
211
  let projectDir = ''
@@ -228,7 +248,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
228
248
  const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
229
249
  const transcript = ctx.sessionManager.getSessionFile()
230
250
  if (transcript) common.transcript_path = transcript
231
- if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
251
+ // Claude vocabulary only: pi's minimal maps to low and off carries no effort.
252
+ const effort = claudeEffortLevel(ctx.thinkingLevel)
253
+ if (effort) common.effort = { level: effort }
232
254
  return common
233
255
  }
234
256
  /** Claude's prompt-hook `model` override, resolved against the models this user
@@ -512,7 +534,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
512
534
  const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
513
535
  const startedAt = toolStartTimes.get(event.toolCallId)
514
536
  toolStartTimes.delete(event.toolCallId)
515
- const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
537
+ // Claude delivers a failure as top-level fields rather than a tool_response: "error
538
+ // information as top-level fields ... error ... is_interrupt". is_interrupt is false
539
+ // here because pi reports a cancelled tool through the result, not this event.
540
+ const failure = event.isError ? { error: textContent(event.content), is_interrupt: false } : { tool_response: response }
541
+ const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
516
542
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
517
543
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
518
544
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
@@ -576,10 +602,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
576
602
  return { action: 'handled' }
577
603
  }
578
604
  // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
579
- // pi's seam for rewriting the submitted text. With suppressOriginalPrompt the
580
- // context replaces the prompt entirely (honored only when context exists, since
581
- // an empty submission would be no turn at all).
582
- if (decision.context) return { action: 'transform', text: decision.suppress ? decision.context : `${decision.context}\n\n${event.text}` }
605
+ // pi's seam for rewriting the submitted text. The prompt itself always survives:
606
+ // "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
607
+ // alongside it". suppressOriginalPrompt scopes to the block message, which never
608
+ // carries the prompt here, so it needs nothing of its own.
609
+ if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
583
610
  return { action: 'continue' }
584
611
  })
585
612
 
@@ -593,12 +620,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
593
620
  // handler on a UI dialog, which would starve the Stop hook and idle notification
594
621
  // until the user answers it. agent_end can fire slightly early before a rare
595
622
  // automatic retry or compaction; that is the better tradeoff.
596
- pi.on('agent_end', async (event, ctx) => {
597
- // Claude's Notification event, for the one type pi can honestly source: the
598
- // agent finished and is waiting for input. Per Claude, idle_prompt fires when
599
- // the turn ended about 60 seconds ago and the user hasn't typed since, so it
600
- // arms here and input or the next turn cancels it. Observational only; exit
601
- // codes and JSON output are ignored, as Claude documents for this event.
623
+ /** Claude's Notification event, for the one type pi can honestly source: the agent
624
+ * finished and is waiting for input. idle_prompt fires when the turn ended about 60
625
+ * seconds ago and the user has not typed since, so it arms here and input or the next
626
+ * turn cancels it. Observational only; exit codes and JSON output are ignored. */
627
+ const armIdlePrompt = (ctx: ExtensionContext): void => {
602
628
  cancelIdlePrompt()
603
629
  const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
604
630
  if (notifyCommands.length > 0) {
@@ -608,6 +634,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
608
634
  }, IDLE_PROMPT_DELAY_MS)
609
635
  idlePromptTimer.unref?.()
610
636
  }
637
+ }
638
+ pi.on('agent_end', async (event, ctx) => {
639
+ armIdlePrompt(ctx)
611
640
 
612
641
  // In a subagent child, the agent-frontmatter Stop hooks were converted to
613
642
  // SubagentStop and fire here, at the child's own end, notify-style; before the
@@ -643,24 +672,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
643
672
  const run = boundRunner(ctx)
644
673
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
645
674
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
675
+ const stopMessages: string[] = []
646
676
  const block = results
647
677
  .filter((result) => !result.timedOut)
648
- .map((result) => {
649
- const parsed = tryParseJson(result.stdout)
650
- if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
651
- // Any JSON blocking spelling counts, including the prompt/agent hook reply
652
- // schemas (permissionDecision deny, ok:false), which arrive as stdout here.
653
- const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
654
- if (verdict) return { block: true, reason: verdict.reason }
655
- // Claude's non-error continue: additionalContext feeds back and the
656
- // conversation continues so Claude can act on it. It rides the same
657
- // continuation path (and the same block cap) so a hook emitting it every
658
- // firing cannot loop the turn forever.
659
- const context = parsed?.hookSpecificOutput?.additionalContext
660
- if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
661
- return { block: false, reason: '' }
662
- })
678
+ .map((result) => stopVerdict(result, stopMessages))
663
679
  .find((verdict) => verdict.block)
680
+ for (const message of stopMessages) ctx.ui.notify(message, 'warning')
664
681
  if (!block) {
665
682
  // A non-blocking Stop breaks the streak: the next block starts a fresh count.
666
683
  stopHookActive = false
@@ -689,6 +706,17 @@ export default function hooksExtension(pi: ExtensionAPI) {
689
706
  const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
690
707
  const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
691
708
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
709
+ // Claude: "Exit with code 2 to block compaction. For a manual /compact, the stderr
710
+ // message is shown to the user. You can also block by returning JSON with
711
+ // `decision: block`." pi cancels through the result, and a blocked automatic
712
+ // compaction is worth a notice too: the context stays full either way.
713
+ for (const [index, result] of results.entries()) {
714
+ const parsed = tryParseJson(result.stdout)
715
+ const blocked = result.code === 2 && !result.timedOut ? { reason: result.stderr.trim() || 'Compaction blocked by hook' } : jsonBlockVerdict(parsed, 'Compaction blocked by hook')
716
+ if (!blocked) continue
717
+ ctx.ui.notify(`Compaction blocked by ${matchingCommands(config.PreCompact, trigger.names)[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
718
+ return { cancel: true }
719
+ }
692
720
  })
693
721
 
694
722
  pi.on('session_compact', async (event, ctx) => {
@@ -4,7 +4,7 @@
4
4
  * commands an event fires. Owns the module-level compiled-matcher cache.
5
5
  */
6
6
 
7
- import { matchesBashRules } from '../internal/bash-rules.js'
7
+ import { matchesBashIfFilter } from '../internal/bash-rules.js'
8
8
  import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
9
9
  import type { HookCommand, HookMatcher } from './config.js'
10
10
 
@@ -159,14 +159,16 @@ export interface IfFilterTarget {
159
159
 
160
160
  /** Claude's `if` handler field: permission-rule syntax evaluated only on tool
161
161
  * events; on any other event a hook carrying `if` never runs. A bare tool name
162
- * matches by name; `Bash(pattern)` evaluates against the command via the shared
163
- * bash-rule matcher and file-tool patterns against the path via the shared
164
- * permission path rules. A pattern for any other tool matches nothing, which is
162
+ * matches by name; `Bash(pattern)` evaluates against the command through the if-filter
163
+ * matcher, which is best effort and errs toward running the hook, and file-tool patterns
164
+ * against the path via the shared permission path rules. A pattern for any other tool matches nothing, which is
165
165
  * also what an unparseable rule does. */
166
166
  export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undefined): boolean {
167
167
  if (hook.if === undefined) return true
168
168
  if (target === undefined) return false
169
- const parsed = /^([A-Za-z_|]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
169
+ // Digits and hyphens are part of a tool name: an MCP tool is mcp__<server>__<tool> and
170
+ // server names carry both, so a stricter class silently matched nothing.
171
+ const parsed = /^([\w|-]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
170
172
  if (!parsed) return false
171
173
  const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
172
174
  const ruleTools = new Set(parsed[1].split('|').map(fold))
@@ -177,7 +179,7 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
177
179
  const input = target.input as Record<string, unknown> | null
178
180
  if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
179
181
  const command = typeof input?.command === 'string' ? input.command : ''
180
- return command.length > 0 && matchesBashRules(command, [pattern])
182
+ return command.length > 0 && matchesBashIfFilter(command, pattern)
181
183
  }
182
184
  let filePath = ''
183
185
  if (typeof input?.path === 'string') filePath = input.path
@@ -30,6 +30,50 @@ function matchesRule(segment: string, rule: string): boolean {
30
30
  return segment === normalized
31
31
  }
32
32
 
33
+ /** Leading `VAR=value` assignments, which Claude strips before matching an `if` pattern. */
34
+ const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/
35
+
36
+ /** The bodies of `$(...)`, `` `...` ``, `<(...)` and `>(...)`, which run commands of their own. */
37
+ function substitutionBodies(command: string): string[] {
38
+ const bodies: string[] = []
39
+ for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`|[<>]\(([^()]*)\)/g)) {
40
+ const body = match[1] ?? match[2] ?? match[3]
41
+ if (body?.trim()) bodies.push(body.trim())
42
+ }
43
+ return bodies
44
+ }
45
+
46
+ /** Whether the first word of a segment is something only the shell can resolve. */
47
+ const headUnresolvable = (segment: string): boolean => /[$`]/.test(segment.split(/\s+/, 1)[0] ?? '')
48
+
49
+ /** Whether a pattern names more than the command itself, like `git push *` against `git *`. */
50
+ const namesMoreThanCommand = (rule: string): boolean => rule.split('*', 1)[0].trim().includes(' ')
51
+
52
+ /**
53
+ * Claude's `if` filter for a Bash call, which decides whether a hook gets to SEE the
54
+ * call. That makes it the mirror of a permission rule: a grant must hold for every
55
+ * segment and fails closed on anything it cannot read, while this runs the hook when any
56
+ * segment matches and when the input cannot be resolved at all. Claude: "When Claude Code
57
+ * can't determine which commands the Bash input runs, it runs your hook regardless of the
58
+ * pattern. Because the `if` filter is best-effort, use the permission system rather than
59
+ * a hook to enforce a hard allow or deny."
60
+ *
61
+ * Each top-level segment is checked with its leading assignments stripped, and so is the
62
+ * body of every substitution, since one can sit at any argument position. An unresolvable
63
+ * command name runs the hook whatever the pattern; a substitution anywhere runs it when
64
+ * the pattern names more than the command.
65
+ */
66
+ export function matchesBashIfFilter(command: string, rule: string): boolean {
67
+ const trimmed = rule.trim()
68
+ const candidates = splitSegments(command).flatMap((segment) => {
69
+ const stripped = segment.replace(LEADING_ASSIGNMENTS, '')
70
+ return [stripped, ...substitutionBodies(stripped).flatMap((body) => splitSegments(body).map((inner) => inner.replace(LEADING_ASSIGNMENTS, '')))]
71
+ })
72
+ if (candidates.some((candidate) => matchesRule(candidate, trimmed))) return true
73
+ if (candidates.some(headUnresolvable)) return true
74
+ return namesMoreThanCommand(trimmed) && (hasSubstitution(command) || /\$[A-Za-z_{]/.test(command))
75
+ }
76
+
33
77
  export function matchesBashRules(command: string, rules: string[]): boolean {
34
78
  if (hasSubstitution(command)) return false
35
79
  const segments = splitSegments(command)
@@ -377,7 +377,14 @@ export function substituteArgsDetailed(body: string, args: string, names: string
377
377
  return all || argsDefault
378
378
  }
379
379
  if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
380
- if (name !== undefined) return fill(parts[names.indexOf(name)], '')
380
+ if (name !== undefined) {
381
+ // Claude: "A named placeholder counts even when its position has no argument,
382
+ // because it expands to an empty string", unlike an indexed one, which stays
383
+ // literal and does not count. Otherwise a skill using named arguments still got
384
+ // the ARGUMENTS: block appended.
385
+ consumed = true
386
+ return fill(parts[names.indexOf(name)], '')
387
+ }
381
388
  consumed = true
382
389
  return all // $ARGUMENTS or $@
383
390
  })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * pi's thinking levels translated to Claude's effort vocabulary, which is
3
+ * `low | medium | high | xhigh | max`. pi adds two levels outside it: `minimal`, which
4
+ * reads as Claude's lowest, and `off`, which has no effort at all. Every surface that
5
+ * exposes the level to a script or a hook goes through here, so `off` and `minimal`
6
+ * never leak into a payload or an environment variable that promises Claude's set.
7
+ */
8
+
9
+ /** The Claude effort level for a pi thinking level, or undefined when thinking is off. */
10
+ export function claudeEffortLevel(thinkingLevel: string | undefined): string | undefined {
11
+ if (!thinkingLevel || thinkingLevel === 'off') return undefined
12
+ return thinkingLevel === 'minimal' ? 'low' : thinkingLevel
13
+ }
@@ -121,8 +121,11 @@ export class FileOAuthProvider implements OAuthClientProvider {
121
121
  }
122
122
  }
123
123
 
124
+ /** The localhost spelling, which is what a server with a pre-registered redirect URI
125
+ * expects: Claude sent the 127.0.0.1 form for one version and "servers that exact-match
126
+ * the registered redirect URI rejected the sign-in with a redirect URI mismatch". */
124
127
  get redirectUrl(): string {
125
- return `http://127.0.0.1:${this.port}/callback`
128
+ return `http://localhost:${this.port}/callback`
126
129
  }
127
130
 
128
131
  get clientMetadata(): OAuthClientMetadata {
@@ -193,13 +196,24 @@ export class FileOAuthProvider implements OAuthClientProvider {
193
196
  * taken, an ephemeral port is used. */
194
197
  export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
195
198
  const server = http.createServer()
196
- const listen = (port: number): Promise<void> => new Promise((resolve, reject) => server.listen(port, '127.0.0.1', resolve).once('error', reject))
199
+ const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
197
200
  try {
198
- await listen(preferredPort ?? 0)
201
+ await listen(preferredPort ?? 0, '127.0.0.1')
199
202
  } catch {
200
- await listen(0)
203
+ await listen(0, '127.0.0.1')
201
204
  }
202
- return { server, port: (server.address() as { port: number }).port }
205
+ const port = (server.address() as { port: number }).port
206
+ // The redirect names localhost, which resolves to ::1 first on a host with IPv6, so a
207
+ // second listener answers there too. Best effort: where it cannot bind, the IPv4
208
+ // listener above still answers every browser that resolves localhost to 127.0.0.1.
209
+ const ipv6 = http.createServer()
210
+ ipv6.on('error', () => {})
211
+ await new Promise<void>((resolve) => {
212
+ ipv6.listen(port, '::1', () => resolve()).once('error', () => resolve())
213
+ })
214
+ server.on('close', () => ipv6.close())
215
+ ipv6.on('request', (request, response) => server.emit('request', request, response))
216
+ return { server, port }
203
217
  }
204
218
 
205
219
  export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
@@ -33,6 +33,8 @@ const CLAUDE_SHAPED = [
33
33
  path.join('.claude', 'rules'),
34
34
  path.join('.claude', 'skills'),
35
35
  path.join('.claude', 'commands'),
36
+ // Injected into the prompt verbatim, and context-imports treats it as approval-gated.
37
+ path.join('.claude', 'CLAUDE.md'),
36
38
  'CLAUDE.local.md',
37
39
  '.mcp.json',
38
40
  path.join('.pi', 'mcp.json'),
@@ -21,6 +21,10 @@ export interface StdioServerConfig {
21
21
  timeout?: number
22
22
  /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
23
23
  aliasPrefix?: string
24
+ /** The server name as its manifest declares it, without the plugin: scope the registry
25
+ * key carries. pi-side tool names derive from this, so a plugin server contributes
26
+ * <server>_<tool> the way a user server does. */
27
+ baseName?: string
24
28
  /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
25
29
  pluginRoot?: string
26
30
  /** Loaded from the project scope, whose helpers run credential-stripped. */
@@ -42,6 +46,10 @@ export interface HttpServerConfig {
42
46
  timeout?: number
43
47
  /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
44
48
  aliasPrefix?: string
49
+ /** The server name as its manifest declares it, without the plugin: scope the registry
50
+ * key carries. pi-side tool names derive from this, so a plugin server contributes
51
+ * <server>_<tool> the way a user server does. */
52
+ baseName?: string
45
53
  /** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
46
54
  pluginRoot?: string
47
55
  /** Loaded from the project scope, whose helpers run credential-stripped. */
@@ -213,7 +221,11 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
213
221
  for (const plugin of plugins) {
214
222
  for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
215
223
  const substituted = substitutedPluginServer(plugin, name, config, projectDir)
216
- if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, pluginRoot: plugin.root }
224
+ // Claude: "The server itself registers under the scoped name
225
+ // plugin:<plugin-name>:<server-name>", which is what an mcp_tool hook names and what
226
+ // keeps a same-named user server from replacing a plugin's. The tool alias keeps its
227
+ // own flat spelling, mcp__plugin_<plugin>_<server>__<tool>.
228
+ if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root }
217
229
  }
218
230
  }
219
231
  return servers
@@ -131,7 +131,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
131
131
  function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
132
132
  let count = 0
133
133
  for (const tool of tools) {
134
- const toolName = formatToolName(name, tool.name)
134
+ // A plugin server's registry key carries its plugin: scope; its tools keep the bare
135
+ // server name, as their Claude alias does.
136
+ const toolName = formatToolName(config.baseName ?? name, tool.name)
135
137
  const owner = registered.get(toolName)
136
138
  if (owner === name) continue // already registered for this server: a refresh re-listing it
137
139
  if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
@@ -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
  }
@@ -284,12 +284,33 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
284
284
  }
285
285
  }
286
286
 
287
+ /** Transport-level failure codes worth another attempt. */
288
+ const TRANSIENT_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'])
289
+
290
+ /** Every `code` reachable from an error: its own, those of its `cause` chain, and those
291
+ * of an AggregateError's members. fetch reports a refused connection as a bare
292
+ * `TypeError: fetch failed` whose cause carries ECONNREFUSED, so the text says nothing. */
293
+ function errorCodes(error: unknown, seen = new Set<unknown>()): string[] {
294
+ if (error === null || typeof error !== 'object' || seen.has(error)) return []
295
+ seen.add(error)
296
+ const codes: string[] = []
297
+ const code = (error as { code?: unknown }).code
298
+ if (typeof code === 'string') codes.push(code)
299
+ const inner = (error as { errors?: unknown }).errors
300
+ if (Array.isArray(inner)) for (const one of inner) codes.push(...errorCodes(one, seen))
301
+ codes.push(...errorCodes((error as { cause?: unknown }).cause, seen))
302
+ return codes
303
+ }
304
+
287
305
  /** Whether a connect failure is worth retrying: a 5xx response, a refused or reset
288
- * connection, or a timeout. Auth and not-found errors need a configuration change. */
289
- function isTransientConnectError(error: unknown): boolean {
306
+ * connection, or a timeout, per Claude's "a 5xx response, a connection refused, or a
307
+ * timeout ... retries up to three times". Auth and not-found errors need a configuration
308
+ * change instead. Exported for the test that pins the shape node actually throws. */
309
+ export function isTransientConnectError(error: unknown): boolean {
290
310
  if (isUnauthorized(error)) return false
291
- const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
292
- if (typeof code === 'number') return code >= 500
311
+ const status = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
312
+ if (typeof status === 'number') return status >= 500
313
+ if (errorCodes(error).some((code) => TRANSIENT_CODES.has(code))) return true
293
314
  return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
294
315
  }
295
316
 
@@ -315,9 +336,11 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
315
336
  * environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
316
337
  * or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
317
338
  * yields no extra headers rather than blocking the connection. */
318
- 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>> {
319
340
  return new Promise((resolve) => {
320
- 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)
321
344
  if (!shell) {
322
345
  resolve({})
323
346
  return
@@ -80,6 +80,9 @@ export function resolveMemoryDir(cwd: string, override?: string): string {
80
80
  export function autoMemoryEnabled(setting: unknown, env: NodeJS.ProcessEnv): boolean {
81
81
  const disable = (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY ?? '').trim().toLowerCase()
82
82
  if (disable === '1' || disable === 'true') return false
83
+ // Claude: "Set to 0 to force auto memory on even when --bare mode or
84
+ // autoMemoryEnabled: false would otherwise disable it."
85
+ if (disable === '0' || disable === 'false') return true
83
86
  return setting !== false
84
87
  }
85
88
 
@@ -536,7 +539,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
536
539
  ` Auto memory: ${isEnabled ? 'on' : 'off'}`,
537
540
  ` Store: ${store}`,
538
541
  ` Index: ${path.join(store, INDEX_FILE)}`,
539
- ` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
542
+ // The loader reads it from the configured directory, so CLAUDE_CONFIG_DIR moves it.
543
+ ` User memory (CLAUDE.md): ${path.join(claudeConfigDir(home), 'CLAUDE.md')}`,
540
544
  ` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
541
545
  // Claude's /memory lists every documented location, including files that
542
546
  // do not exist yet.
@@ -31,8 +31,8 @@ import * as fs from 'node:fs'
31
31
  import * as os from 'node:os'
32
32
  import * as path from 'node:path'
33
33
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
34
-
35
34
  import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
35
+ import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
38
  import { isProjectApprovedSilently } from './internal/project-approval.js'
@@ -296,10 +296,10 @@ export default function statusLine(pi: ExtensionAPI) {
296
296
  const sessionName = ctx.sessionManager.getSessionName?.()
297
297
  if (sessionName) payload.session_name = sessionName
298
298
  if (ctx.thinkingLevel) {
299
- // pi's off/minimal are outside Claude's effort vocabulary: minimal maps to
300
- // low, and off omits effort entirely (thinking disabled says the rest).
299
+ // Thinking disabled says the rest, so off carries no effort field of its own.
301
300
  payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
302
- if (ctx.thinkingLevel !== 'off') payload.effort = { level: ctx.thinkingLevel === 'minimal' ? 'low' : ctx.thinkingLevel }
301
+ const effort = claudeEffortLevel(ctx.thinkingLevel)
302
+ if (effort) payload.effort = { level: effort }
303
303
  }
304
304
  if (styleName) payload.output_style = { name: styleName }
305
305
  // The current utilization of the account's rate-limit windows, when the
@@ -764,7 +764,7 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
764
764
  /** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
765
765
  * does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
766
766
  * extensions register in a child. Claude's capitalized spellings fold onto these. */
767
- const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource'])
767
+ const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource', 'todo', 'question', 'memory', 'slash_command', 'plan_mode_complete'])
768
768
 
769
769
  /** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
770
770
  * to launch with an error naming the entries, instead of running tool-less. */
@@ -785,7 +785,12 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
785
785
  // order (invocation model, frontmatter model, this variable, the session model).
786
786
  // pi reads a thinking level from the model pattern's :suffix when a model is
787
787
  // pinned, and from --thinking otherwise.
788
- const model = agent.model ?? aliasModel ?? process.env.CLAUDE_CODE_SUBAGENT_MODEL
788
+ // Claude exempts the two built-ins from the environment variable: "Setting
789
+ // CLAUDE_CODE_SUBAGENT_MODEL by itself doesn't change the model the built-in Explore and
790
+ // Plan subagents run on." A model they name themselves, or one the invocation names,
791
+ // still applies.
792
+ const exemptFromEnvModel = agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')
793
+ const model = agent.model ?? aliasModel ?? (exemptFromEnvModel ? undefined : process.env.CLAUDE_CODE_SUBAGENT_MODEL)
789
794
  if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
790
795
  else if (agent.effort) args.push('--thinking', agent.effort)
791
796
  // Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
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",