pi-code 1.0.51 → 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.
@@ -39,12 +39,47 @@ function unquote(value: string): string {
39
39
  return value.replace(/^["']|["']$/g, '')
40
40
  }
41
41
 
42
+ /** Split a YAML flow sequence on its top-level commas. A comma inside a brace group
43
+ * (`*.{ts,tsx}`, which Claude expands) or inside quotes belongs to its entry, so
44
+ * splitting on every comma produced patterns that match nothing. */
42
45
  function splitInline(value: string): string[] {
43
- return value
44
- .replace(/^\[|\]$/g, '')
45
- .split(',')
46
- .map((entry) => unquote(entry.trim()))
47
- .filter(Boolean)
46
+ const entries: string[] = []
47
+ let current = ''
48
+ let depth = 0
49
+ let quote: string | undefined
50
+ for (const char of value.replace(/^\[/, '').replace(/\]$/, '')) {
51
+ if (quote !== undefined) {
52
+ if (char === quote) quote = undefined
53
+ current += char
54
+ continue
55
+ }
56
+ if (char === '"' || char === "'") {
57
+ quote = char
58
+ current += char
59
+ continue
60
+ }
61
+ if (char === '{') depth++
62
+ else if (char === '}') depth = Math.max(0, depth - 1)
63
+ else if (char === ',' && depth === 0) {
64
+ entries.push(current)
65
+ current = ''
66
+ continue
67
+ }
68
+ current += char
69
+ }
70
+ entries.push(current)
71
+ return entries.map((entry) => unquote(entry.trim())).filter(Boolean)
72
+ }
73
+
74
+ /** A flow sequence that opens on the `paths:` line and closes on a later one, joined
75
+ * back into a single value. An unterminated list runs to the end of the frontmatter. */
76
+ function joinFlowSequence(lines: string[], index: number, opening: string): string {
77
+ let joined = opening
78
+ for (let i = index + 1; i < lines.length; i++) {
79
+ joined += ` ${lines[i].trim()}`
80
+ if (lines[i].includes(']')) break
81
+ }
82
+ return joined
48
83
  }
49
84
 
50
85
  function parsePaths(frontmatter: string): string[] {
@@ -52,7 +87,7 @@ function parsePaths(frontmatter: string): string[] {
52
87
  const index = lines.findIndex((line) => /^\s*paths\s*:/.test(line))
53
88
  if (index === -1) return []
54
89
  const inline = lines[index].replace(/^\s*paths\s*:/, '').trim()
55
- if (inline) return splitInline(inline)
90
+ if (inline) return splitInline(inline.startsWith('[') && !inline.includes(']') ? joinFlowSequence(lines, index, inline) : inline)
56
91
  const items: string[] = []
57
92
  // Matched with string ops rather than a regex: the equivalent pattern needs two
58
93
  // adjacent whitespace quantifiers, which backtracks super-linearly on long lines.
@@ -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,
@@ -40,7 +40,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
40
40
  import { claudeConfigDir } from './internal/config-dir.js'
41
41
  import { readManagedSettings } from './internal/managed-settings.js'
42
42
  import { isProjectApprovedSilently } from './internal/project-approval.js'
43
- import { findNearestFile } from './internal/project-root.js'
43
+ import { claudeSettingsChain } from './internal/settings-chain.js'
44
44
 
45
45
  function isRecord(value: unknown): value is Record<string, unknown> {
46
46
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -137,12 +137,14 @@ export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: stri
137
137
  return kept
138
138
  }
139
139
 
140
- /** The project scope's env: .claude/settings.json with settings.local.json overlaid,
141
- * each the nearest of its name at or above cwd (matching the hooks settings chain). */
142
- function projectEnv(cwd: string): Record<string, string> {
143
- const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
144
- const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
145
- return sanitizeProjectEnv({ ...base, ...local })
140
+ /** The project scope's env, resolved through the shared settings chain so placement
141
+ * matches every other consumer: the shared settings.json comes from the session's own
142
+ * directory and never an ancestor, settings.local.json from the repository root. Later
143
+ * files win. */
144
+ function projectEnv(cwd: string, home: string): Record<string, string> {
145
+ const merged: Record<string, string> = {}
146
+ for (const file of claudeSettingsChain(cwd, home, true).slice(1)) Object.assign(merged, envFromSettings(readSettingsFile(file)))
147
+ return sanitizeProjectEnv(merged)
146
148
  }
147
149
 
148
150
  export default function envSettingsExtension(pi: ExtensionAPI) {
@@ -157,6 +159,6 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
157
159
  apply(os.homedir(), {})
158
160
 
159
161
  pi.on('session_start', async (_event, ctx: ExtensionContext) => {
160
- apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
162
+ apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd, os.homedir()) : {})
161
163
  })
162
164
  }
@@ -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 })
@@ -147,7 +147,19 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
147
147
  // Claude shows a `<hook> hook error` notice when {..}-shaped stdout cannot be
148
148
  // read as JSON output (exit 2 still blocks and reads its own channels).
149
149
  const jsonError = result.code === 2 ? undefined : hookJsonError(result.stdout)
150
- if (jsonError !== undefined) notify(`${commands[i].command} hook error: ${jsonError}`)
150
+ if (jsonError !== undefined) {
151
+ notify(`${commands[i].command} hook error: ${jsonError}`)
152
+ continue
153
+ }
154
+ // A non-zero exit that is neither a block (2) nor a timeout, with nothing parseable
155
+ // on stdout, is Claude's non-blocking error: the action proceeds and the notice
156
+ // carries the first line of stderr. Without it a mistyped path in settings.json
157
+ // leaves a policy hook silently disabled, since the shell exits 127 and says so only
158
+ // on stderr. A spawn failure is reported above and skipped here.
159
+ if (!result.spawnFailed && !result.timedOut && result.code !== 0 && result.code !== 2) {
160
+ const firstLine = result.stderr.trim().split('\n')[0]
161
+ notify(`${commands[i].command} hook error: Failed with non-blocking status code: ${firstLine || result.code}`)
162
+ }
151
163
  }
152
164
  }
153
165
 
@@ -250,8 +262,6 @@ export interface PromptDecision {
250
262
  block: boolean
251
263
  reason?: string
252
264
  context: string
253
- /** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
254
- suppress?: boolean
255
265
  }
256
266
 
257
267
  /** Additional context a UserPromptSubmit hook contributes: an explicit
@@ -279,17 +289,13 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
279
289
  }
280
290
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
281
291
  const contexts: string[] = []
282
- let suppress = false
283
292
  for (const result of results) {
284
293
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
285
294
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
286
- // Claude's suppressOriginalPrompt: any hook setting it hides the original
287
- // prompt, and the collected context is what reaches the model.
288
- if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
289
295
  const context = promptContext(result.stdout)
290
296
  if (context) contexts.push(context)
291
297
  }
292
- return { block: false, context: contexts.join('\n'), suppress }
298
+ return { block: false, context: contexts.join('\n') }
293
299
  }
294
300
 
295
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
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Replace a file through a temp file and a rename, so a crash mid-write cannot leave the
3
+ * target truncated. Used wherever pi-code rewrites a file the user owns and would have to
4
+ * repair by hand: settings and the memory index.
5
+ */
6
+
7
+ import * as fs from 'node:fs'
8
+
9
+ /** The tmp name carries the pid so concurrent processes do not collide. */
10
+ export function atomicWriteFile(filePath: string, content: string): void {
11
+ const tmp = `${filePath}.${process.pid}.tmp`
12
+ fs.writeFileSync(tmp, content)
13
+ fs.renameSync(tmp, filePath)
14
+ }
@@ -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> {
@@ -244,7 +258,12 @@ export function openBrowser(url: string): void {
244
258
  else if (process.platform === 'win32') command = 'cmd'
245
259
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
246
260
  try {
247
- spawn(command, args, { stdio: 'ignore', detached: true }).unref()
261
+ const child = spawn(command, args, { stdio: 'ignore', detached: true })
262
+ // A missing launcher (a container or an SSH session with no xdg-open) reports itself
263
+ // asynchronously through 'error'; with no listener node raises it as an
264
+ // uncaughtException and pi exits. The caller has already shown the URL to open by hand.
265
+ child.on('error', () => {})
266
+ child.unref()
248
267
  } catch {
249
268
  // the notified URL is the fallback
250
269
  }
@@ -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) {
@@ -563,8 +565,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
563
565
  // is why serverToolCount reads from `registered` to recover the true count here.
564
566
  status.clear()
565
567
  // A same-process session switch (/new, /resume) shut the last session down;
566
- // this one may reconnect again.
568
+ // this one may reconnect again. projectConnected guards against connecting the project
569
+ // scope twice within one session, so it belongs to the session that set it: leaving it
570
+ // set here left every project server disconnected for the rest of the process, since
571
+ // the shutdown had already dropped their clients.
567
572
  shuttingDown = false
573
+ projectConnected = false
568
574
  // Claude answers roots/list with the session's launch directory and exports the
569
575
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
570
576
  sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
@@ -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
 
@@ -14,6 +14,7 @@ import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
15
  import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
+ import { atomicWriteFile } from './internal/atomic-write.js'
17
18
  import { claudeConfigDir } from './internal/config-dir.js'
18
19
  import { readManagedSettings } from './internal/managed-settings.js'
19
20
  import { capForContext } from './internal/output-guard.js'
@@ -79,6 +80,9 @@ export function resolveMemoryDir(cwd: string, override?: string): string {
79
80
  export function autoMemoryEnabled(setting: unknown, env: NodeJS.ProcessEnv): boolean {
80
81
  const disable = (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY ?? '').trim().toLowerCase()
81
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
82
86
  return setting !== false
83
87
  }
84
88
 
@@ -322,14 +326,6 @@ function readIndexQuietly(dir: string): string {
322
326
  }
323
327
  }
324
328
 
325
- /** Write through a temp file and rename onto the target, so a crash mid-write cannot
326
- * truncate it. The tmp name carries the pid so concurrent processes do not collide. */
327
- function atomicWriteFile(filePath: string, content: string): void {
328
- const tmp = `${filePath}.${process.pid}.tmp`
329
- fs.writeFileSync(tmp, content)
330
- fs.renameSync(tmp, filePath)
331
- }
332
-
333
329
  /** Replace the index through a rename so a crash mid-write cannot truncate it. */
334
330
  function writeIndex(indexPath: string, content: string): void {
335
331
  atomicWriteFile(indexPath, content)
@@ -543,7 +539,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
543
539
  ` Auto memory: ${isEnabled ? 'on' : 'off'}`,
544
540
  ` Store: ${store}`,
545
541
  ` Index: ${path.join(store, INDEX_FILE)}`,
546
- ` 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')}`,
547
544
  ` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
548
545
  // Claude's /memory lists every documented location, including files that
549
546
  // do not exist yet.
@@ -23,6 +23,7 @@ import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
+ import { atomicWriteFile } from './internal/atomic-write.js'
26
27
 
27
28
  import { claudeConfigDir } from './internal/config-dir.js'
28
29
  import { readManagedSettings } from './internal/managed-settings.js'
@@ -101,10 +102,10 @@ function isDirectory(target: string): boolean {
101
102
  */
102
103
  export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
103
104
  const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
104
- // Claude loads every .claude/output-styles between cwd and the repository root,
105
- // using the one closest to cwd for a name clash: styleForName is first-match,
106
- // so the nearest directory goes first.
107
- if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')))
105
+ // Claude loads every .claude/output-styles between cwd and the repository root, using
106
+ // the one closest to cwd for a name clash. loadStyles keys by name and later dirs win,
107
+ // so the ancestors go farthest first and the nearest lands last.
108
+ if (trusted) dirs.push(...ancestorDirs(cwd, path.join('.claude', 'output-styles')).reverse())
108
109
  return dirs.filter((dir) => isDirectory(dir))
109
110
  }
110
111
 
@@ -173,16 +174,31 @@ export function styleForName(styles: OutputStyle[], name: string | undefined): O
173
174
  return name ? styles.find((style) => style.name === name) : undefined
174
175
  }
175
176
 
176
- function persistActiveStyle(file: string, name: string): void {
177
- let config: Record<string, unknown> = {}
177
+ /** Record the choice in the local settings file, returning a message to show when it
178
+ * cannot be recorded. The file also carries permissions, hooks and env, so a present but
179
+ * unparseable one is left alone rather than replaced by the style choice alone. */
180
+ function persistActiveStyle(file: string, name: string): string | undefined {
181
+ const label = path.basename(file)
182
+ let raw: string | undefined
178
183
  try {
179
- config = JSON.parse(fs.readFileSync(file, 'utf-8'))
180
- } catch {
181
- // start from an empty config when the file is missing or invalid
184
+ raw = fs.readFileSync(file, 'utf-8')
185
+ } catch (error) {
186
+ // Only a missing file means start fresh; anything else is the user's file to keep.
187
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return `${label} could not be read; the style applies to this session only`
188
+ }
189
+ let config: Record<string, unknown> = {}
190
+ if (raw !== undefined) {
191
+ try {
192
+ const parsed: unknown = JSON.parse(raw)
193
+ if (parsed !== null && typeof parsed === 'object') config = parsed as Record<string, unknown>
194
+ } catch {
195
+ return `${label} is not valid JSON; the style applies to this session only`
196
+ }
182
197
  }
183
198
  config.outputStyle = name
184
199
  fs.mkdirSync(path.dirname(file), { recursive: true })
185
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`)
200
+ atomicWriteFile(file, `${JSON.stringify(config, null, 2)}\n`)
201
+ return undefined
186
202
  }
187
203
 
188
204
  export default function outputStylesExtension(pi: ExtensionAPI) {
@@ -236,8 +252,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
236
252
  return
237
253
  }
238
254
  activeName = picked.name
239
- persistActiveStyle(localSettingsPath, picked.name)
240
- ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
255
+ const failure = persistActiveStyle(localSettingsPath, picked.name)
256
+ ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
241
257
  return
242
258
  }
243
259
  if (!ctx.hasUI) {
@@ -253,8 +269,8 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
253
269
  if (!choice) return
254
270
  const picked = styles[labels.indexOf(choice)]
255
271
  activeName = picked.name
256
- persistActiveStyle(localSettingsPath, picked.name)
257
- ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
272
+ const failure = persistActiveStyle(localSettingsPath, picked.name)
273
+ ctx.ui.notify(failure ?? `Output style set to ${picked.name} (applies next turn)`, failure ? 'error' : 'info')
258
274
  },
259
275
  })
260
276
  }
@@ -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
@@ -457,14 +457,20 @@ export default function statusLine(pi: ExtensionAPI) {
457
457
  // Claude's disableAllHooks also turns off the custom statusLine command; the
458
458
  // built-in segment still renders as the fallback.
459
459
  config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
460
- if (config?.refreshInterval) {
461
- refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
460
+ // Re-armed rather than armed once: a refreshInterval added or changed mid-session
461
+ // had no effect until the next session, though Claude applies a settings change as
462
+ // soon as the file is saved.
463
+ const armRefresh = (): void => {
464
+ clearInterval(refreshTimer)
465
+ refreshTimer = config?.refreshInterval ? setInterval(() => scheduleRefresh(), config.refreshInterval * 1000) : undefined
462
466
  }
467
+ armRefresh()
463
468
  // Claude re-runs the script when the statusLine settings change mid-session; a
464
469
  // command change re-resolves and re-runs.
465
470
  disposeSettingsWatch()
466
471
  disposeSettingsWatch = watchSettingsFiles(files, () => {
467
472
  config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
473
+ armRefresh()
468
474
  scheduleRefresh()
469
475
  })
470
476
  show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
@@ -177,6 +177,13 @@ export function backgroundStatusText(): string {
177
177
  return formatStatus(runs.values())
178
178
  }
179
179
 
180
+ /** Every run this process knows about, live and finished. The registry is module state
181
+ * that outlives a session switch; a per-session list is not, because pi re-invokes each
182
+ * extension factory per session. */
183
+ export function allBackgroundRuns(): BackgroundRun[] {
184
+ return [...runs.values()]
185
+ }
186
+
180
187
  /** A finished run, so a caller can continue its session with a follow-up task. */
181
188
  export function backgroundRun(id: string): BackgroundRun | undefined {
182
189
  return runs.get(id)
@@ -200,7 +207,10 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
200
207
  const rebuilt = withRebuiltPrompt(run.spawn)
201
208
  run.spawn = { ...run.spawn, args: rebuilt.args }
202
209
  if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
203
- const args = rebuilt.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
210
+ // The task prompt is always the final argument: both spawn paths push it last and the
211
+ // invocation helper only prepends. Matching a 'Task: ' prefix missed it whenever
212
+ // SubagentStart hooks placed their context ahead of it, so the resume re-ran the old task.
213
+ const args = rebuilt.args.map((arg, index) => (index === rebuilt.args.length - 1 ? `Task: ${task}` : arg))
204
214
  run.state = 'running'
205
215
  run.task = task
206
216
  run.output = undefined
@@ -34,7 +34,7 @@ import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
34
34
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
35
35
  import { skillDirs } from '../skills.js'
36
36
  import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
37
- import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
37
+ import { activeBackgroundRuns, allBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
38
38
  import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
39
39
  import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
40
40
 
@@ -388,8 +388,11 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
388
388
  resolve(code ?? 0)
389
389
  })
390
390
 
391
- proc.on('error', () => {
391
+ proc.on('error', (error: Error) => {
392
392
  cleanup()
393
+ // A child that never started leaves no stdout and no stderr, so this message is the
394
+ // only diagnostic; without it the failure reads as "(no output)".
395
+ if (!currentResult.stderr) currentResult.stderr = error.message
393
396
  resolve(1)
394
397
  })
395
398
 
@@ -566,7 +569,7 @@ function runOutputTail(run: BackgroundRunView): string | undefined {
566
569
  /** The /tasks listing: one line per background run, plus the short output tail the
567
570
  * registry's own status lines omit. A pure formatter so it tests against a plain list. */
568
571
  export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
569
- if (runs.length === 0) return 'No background subagent runs in this session.'
572
+ if (runs.length === 0) return 'No background subagent runs.'
570
573
  return runs
571
574
  .map((run) => {
572
575
  const plural = run.turns === 1 ? '' : 's'
@@ -761,7 +764,7 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
761
764
  /** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
762
765
  * does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
763
766
  * extensions register in a child. Claude's capitalized spellings fold onto these. */
764
- 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'])
765
768
 
766
769
  /** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
767
770
  * to launch with an error naming the entries, instead of running tool-less. */
@@ -782,7 +785,12 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
782
785
  // order (invocation model, frontmatter model, this variable, the session model).
783
786
  // pi reads a thinking level from the model pattern's :suffix when a model is
784
787
  // pinned, and from --thinking otherwise.
785
- 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)
786
794
  if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
787
795
  else if (agent.effort) args.push('--thinking', agent.effort)
788
796
  // Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
@@ -858,7 +866,7 @@ interface BackgroundContext {
858
866
  projectApproved: boolean
859
867
  }
860
868
 
861
- async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext, onStarted?: (id: string) => void): Promise<ToolResult> {
869
+ async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
862
870
  const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
863
871
  const task = params.task
864
872
  const agentName = params.agent
@@ -955,7 +963,6 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
955
963
  removeTmpPrompt(tmpPrompt)
956
964
  return backgroundCapResult(makeDetails)
957
965
  }
958
- onStarted?.(id)
959
966
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
960
967
  return {
961
968
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
@@ -1437,21 +1444,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
1437
1444
  if (isMcpToolAliases(data)) setKnownMcpAliases(data)
1438
1445
  })
1439
1446
 
1440
- // /tasks resolves these against the registry at print time. background.ts owns the
1441
- // run records but does not enumerate them, so the ids started here are remembered;
1442
- // a run the registry has since evicted simply drops out of the listing.
1443
- const startedBackgroundRuns = new Set<string>()
1444
-
1445
- // The registry self-caps and evicts old runs, so an id kept here after its record is
1446
- // gone is dead weight. Drop those on every add, bounding the set to the registry's
1447
- // live capacity rather than letting it grow for the whole session.
1448
- const rememberBackgroundRun = (id: string): void => {
1449
- for (const known of startedBackgroundRuns) {
1450
- if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
1451
- }
1452
- startedBackgroundRuns.add(id)
1453
- }
1454
-
1455
1447
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1456
1448
  // Runs through driveRun's guard, same as the background-mode callback above.
1457
1449
  // The stop event fires here too, so SubagentStop hooks see resumed runs end.
@@ -1585,7 +1577,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
1585
1577
 
1586
1578
  if (params.resume) {
1587
1579
  const onResumed = (run: { id: string; agent: string }): void => {
1588
- rememberBackgroundRun(run.id)
1589
1580
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
1590
1581
  }
1591
1582
  return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
@@ -1629,7 +1620,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1629
1620
  // unavailable tier still falls back to the session model.
1630
1621
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1631
1622
 
1632
- if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1623
+ if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved })
1633
1624
 
1634
1625
  const mode: ModeContext = {
1635
1626
  agents,
@@ -1686,8 +1677,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1686
1677
  pi.registerCommand('tasks', {
1687
1678
  description: 'Show background subagent runs',
1688
1679
  handler: async (_args, ctx) => {
1689
- const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
1690
- ctx.ui.notify(tasksStatusText(runs), 'info')
1680
+ ctx.ui.notify(tasksStatusText(allBackgroundRuns()), 'info')
1691
1681
  },
1692
1682
  })
1693
1683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.51",
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",