pi-code 1.0.52 → 1.0.53

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'
@@ -187,7 +187,9 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
187
187
  const varCtx = ctx as unknown as VarContext
188
188
  return {
189
189
  CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
190
- CLAUDE_EFFORT: varCtx.thinkingLevel,
190
+ // Claude vocabulary only, and unset when thinking is off: the variable promises
191
+ // low|medium|high|xhigh|max, and an unset one stays literal in the body.
192
+ CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
191
193
  CLAUDE_SKILL_DIR: path.dirname(filePath),
192
194
  CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
193
195
  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 })
@@ -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) {
@@ -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
 
@@ -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.53",
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",