pi-code 1.0.8 → 1.0.10

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.
@@ -149,6 +149,10 @@ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string
149
149
  export default function gitCheckpointExtension(pi: ExtensionAPI) {
150
150
  const checkpoints = new Map<string, Checkpoint>()
151
151
  let pending: { ref: string; createdAt: string } | undefined
152
+ // A run (one user message) needs a single pre-run snapshot, no matter how many
153
+ // assistant turns it drives. before_agent_start starts a run; the first turn_start
154
+ // then snapshots and clears this, so turns 2..n skip the wasted git work.
155
+ let runNeedsSnapshot = true
152
156
  let shadowDir: string | undefined
153
157
  let workTree: string | undefined
154
158
 
@@ -258,10 +262,24 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
258
262
  }
259
263
  })
260
264
 
261
- // Snapshot code state before the LLM acts in this turn. The user message
262
- // that started the turn is not persisted yet at turn_start (it lands on
263
- // message_end), so the checkpoint is only keyed and saved at turn_end.
265
+ // A new agent loop starts a run: the next turn_start snapshots the pre-run tree.
266
+ // agent_start, not before_agent_start: before_agent_start does not fire for a queued
267
+ // follow-up message delivered through agent.continue, so gating on it would leave that
268
+ // follow-up's user message with no checkpoint. agent_start re-fires per agent.continue
269
+ // (a retry, a compaction, or a follow-up), and the extra snapshot a retry produces is
270
+ // discarded at turn_end, since that user message already has its checkpoint.
271
+ pi.on('agent_start', async () => {
272
+ runNeedsSnapshot = true
273
+ })
274
+
275
+ // Snapshot code state before the LLM acts, once per run. The user message that
276
+ // started the turn is not persisted yet at turn_start (it lands on message_end), so
277
+ // the checkpoint is only keyed and saved at turn_end. The snapshot is awaited here so
278
+ // `git add -A` captures the tree before the model's first edit; turn_end reads the
279
+ // resolved value.
264
280
  pi.on('turn_start', async () => {
281
+ if (!runNeedsSnapshot) return
282
+ runNeedsSnapshot = false
265
283
  pending = await snapshot()
266
284
  })
267
285
 
@@ -62,6 +62,7 @@ import * as path from 'node:path'
62
62
  import type { Api, Model } from '@earendil-works/pi-ai'
63
63
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
64
64
  import { runAgent } from './internal/agent-run.js'
65
+ import { claudeConfigDir } from './internal/config-dir.js'
65
66
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
66
67
  import { readManagedSettings } from './internal/managed-settings.js'
67
68
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
@@ -83,6 +84,9 @@ const DEFAULT_TIMEOUT_S = 60
83
84
  interface HookCommand {
84
85
  type?: string
85
86
  command: string
87
+ /** exec-form: spawn `command` directly with these args and no shell (shell-form when
88
+ * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
89
+ args?: string[]
86
90
  timeout?: number
87
91
  /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
88
92
  url?: string
@@ -122,14 +126,15 @@ export interface HookRunResult {
122
126
  }
123
127
  /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
124
128
  export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
125
- /** The shell path specifically; the statusline reuses it for its own command. */
126
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
129
+ /** The shell path specifically; the statusline reuses it for its own command. With an
130
+ * `args` array it becomes the exec path: `command` is spawned directly with those args. */
131
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
127
132
 
128
133
  /** Settings files to read, newest-winning. Project files load only when trusted, each
129
134
  * the nearest of its name at or above cwd (bounded at the repository root, matching
130
135
  * the approval walk), so a subdirectory session reads the settings that gated it. */
131
136
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
132
- const files = [path.join(home, '.claude', 'settings.json')]
137
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
133
138
  if (!trusted) return files
134
139
  for (const name of ['settings.json', 'settings.local.json']) {
135
140
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -260,16 +265,24 @@ function foldName(name: string): string {
260
265
  return name.toLowerCase().replaceAll('-', '_')
261
266
  }
262
267
 
263
- function exactListApplies(matcher: string, names: readonly string[]): boolean {
264
- const tokens = new Set(
268
+ /** A matcher string's compiled form: a set of folded exact names, or a regex. */
269
+ type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
270
+
271
+ function exactTokens(matcher: string): Set<string> {
272
+ return new Set(
265
273
  matcher
266
274
  .split(/[|,]/)
267
275
  .map((token) => foldName(token.trim()))
268
276
  .filter(Boolean),
269
277
  )
270
- return names.some((name) => tokens.has(foldName(name)))
271
278
  }
272
279
 
280
+ /** Hook config is static per session and dispatch consults every matcher on every
281
+ * event, so each matcher string compiles once. Matchers are few; the bound is a
282
+ * safety net, clearing the (cheap to rebuild) cache rather than evicting. */
283
+ const compiledMatchers = new Map<string, CompiledMatcher>()
284
+ const COMPILED_MATCHER_BOUND = 1000
285
+
273
286
  /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
274
287
  * is reported by name and skipped, so one bad entry costs its own hooks, not the
275
288
  * session's tool calls. */
@@ -290,15 +303,48 @@ function isUsableMatcher(entry: unknown, file: string, event: string): entry is
290
303
  return true
291
304
  }
292
305
 
306
+ let matcherCompiles = 0
307
+
308
+ /** Test seam: matcher compilations performed, for asserting memoization. */
309
+ export function matcherCompileCount(): number {
310
+ return matcherCompiles
311
+ }
312
+
313
+ /** Test seam: drop compiled matchers so a test observes fresh compiles. */
314
+ export function resetMatcherCache(): void {
315
+ compiledMatchers.clear()
316
+ matcherCompiles = 0
317
+ }
318
+
319
+ function compileMatcher(matcher: string): CompiledMatcher {
320
+ const cached = compiledMatchers.get(matcher)
321
+ if (cached !== undefined) return cached
322
+ matcherCompiles += 1
323
+ let compiled: CompiledMatcher
324
+ if (EXACT_MATCHER.test(matcher)) {
325
+ compiled = { tokens: exactTokens(matcher) }
326
+ } else {
327
+ try {
328
+ compiled = { regex: new RegExp(matcher, 'i') }
329
+ } catch {
330
+ // An invalid regex matcher falls back to exact-name matching, as before.
331
+ compiled = { tokens: exactTokens(matcher) }
332
+ }
333
+ }
334
+ if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
335
+ compiledMatchers.set(matcher, compiled)
336
+ return compiled
337
+ }
338
+
293
339
  function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
294
340
  if (!matcher || matcher === '*') return true
295
- if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
296
- try {
297
- const regex = new RegExp(matcher, 'i')
341
+ const compiled = compileMatcher(matcher)
342
+ if ('regex' in compiled) {
343
+ const { regex } = compiled
298
344
  return names.some((name) => regex.test(name))
299
- } catch {
300
- return exactListApplies(matcher, names)
301
345
  }
346
+ const { tokens } = compiled
347
+ return names.some((name) => tokens.has(foldName(name)))
302
348
  }
303
349
 
304
350
  /** A hook entry pi-code can run: a shell command, an http POST, an in-process
@@ -431,14 +477,23 @@ function killTree(child: ChildProcess): void {
431
477
  child.kill('SIGKILL')
432
478
  }
433
479
 
434
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
480
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
435
481
  new Promise((resolve) => {
436
482
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
437
483
  // `detached` makes the shell its own process group leader so the timeout can kill
438
484
  // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
439
- // reference project files regardless of the shell's cwd.
440
- const env = projectDir ? { ...process.env, CLAUDE_PROJECT_DIR: projectDir } : process.env
441
- const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
485
+ // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
486
+ // subprocess Claude spawns, so it is set on the child unconditionally.
487
+ const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
488
+ if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
489
+ // An exec-form hook (an `args` array) spawns the executable directly with those args
490
+ // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
491
+ // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
492
+ // payload survive verbatim). Without args it stays the shell path. Both share the
493
+ // same detached process group, so killTree reaches the descendants either way.
494
+ const file = Array.isArray(args) ? command : '/bin/sh'
495
+ const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
496
+ const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
442
497
  let stdout = ''
443
498
  let stderr = ''
444
499
  let settled = false
@@ -632,6 +687,18 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
632
687
  return ''
633
688
  }
634
689
 
690
+ /** Claude overrides a Stop hook after it blocks this many times in a row with no user
691
+ * progress, ending the turn with a warning rather than looping forever. */
692
+ const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
693
+
694
+ /** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
695
+ * is a positive integer, else the default. A non-positive or malformed value falls back
696
+ * to the default rather than capping at zero (which would suppress the very first block). */
697
+ export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
698
+ const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
699
+ return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
700
+ }
701
+
635
702
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
636
703
  const MAX_TIMEOUT_S = 2_147_483
637
704
 
@@ -792,6 +859,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
792
859
  let allowedHttpHookUrls: string[] | undefined
793
860
  let pendingSessionContext: string[] = []
794
861
  let stopHookActive = false
862
+ /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
863
+ * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
864
+ let stopHookBlockCount = 0
795
865
  let sessionCtx: ExtensionContext | undefined
796
866
  /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
797
867
  let hooksDisabled = false
@@ -815,7 +885,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
815
885
  if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
816
886
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
817
887
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
818
- return runHookCommand(hook.command, merged, ms, projectDir)
888
+ return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
819
889
  }
820
890
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
821
891
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
@@ -955,6 +1025,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
955
1025
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
956
1026
  // prompts the user submitted.
957
1027
  if (event.source === 'extension') return { action: 'continue' }
1028
+ // Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
1029
+ // block cap counts only consecutive blocks with nothing from the user in between.
1030
+ stopHookBlockCount = 0
958
1031
  const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
959
1032
  if (decision.block) {
960
1033
  // pi's input result has no reason channel, so surface why before consuming it.
@@ -1007,8 +1080,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
1007
1080
  return { block: false, reason: '' }
1008
1081
  })
1009
1082
  .find((verdict) => verdict.block)
1010
- stopHookActive = block !== undefined
1011
- if (block) pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
1083
+ if (!block) {
1084
+ // A non-blocking Stop breaks the streak: the next block starts a fresh count.
1085
+ stopHookActive = false
1086
+ stopHookBlockCount = 0
1087
+ return
1088
+ }
1089
+ stopHookBlockCount += 1
1090
+ const cap = stopHookBlockCap()
1091
+ if (stopHookBlockCount >= cap) {
1092
+ // Claude overrides a Stop hook that has blocked cap times in a row with no user
1093
+ // progress: suppress the continuation, warn, and let the turn end so the loop cannot
1094
+ // run forever. Reset the count so a later run (or user turn) starts clean.
1095
+ stopHookActive = false
1096
+ stopHookBlockCount = 0
1097
+ ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
1098
+ return
1099
+ }
1100
+ stopHookActive = true
1101
+ pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
1012
1102
  })
1013
1103
 
1014
1104
  pi.on('session_before_compact', async (event, ctx) => {
@@ -35,7 +35,16 @@ export interface ParsedCommand {
35
35
  * command's injected spans run (see spanExec). */
36
36
  shell?: string
37
37
  model?: string
38
+ /** `effort:` per-command thinking-level override, one of pi's ThinkingLevel values
39
+ * (off/minimal/low/medium/high/xhigh/max); undefined when absent or unrecognized. */
40
+ effort?: string
41
+ /** `when_to_use:` extra trigger text appended to the slash_command tool listing only,
42
+ * never to the user-facing command description. */
43
+ whenToUse?: string
38
44
  disableModelInvocation: boolean
45
+ /** `user-invocable:` false hides the command from the slash-command surface while
46
+ * keeping it callable by the model through the slash_command tool. Default true. */
47
+ userInvocable: boolean
39
48
  body: string
40
49
  }
41
50
 
@@ -236,6 +245,15 @@ const text = (value: unknown): string => {
236
245
  const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
237
246
  const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
238
247
 
248
+ /** YAML's negative boolean spellings, the mirror of YAML_TRUE. A flag that defaults to
249
+ * true (user-invocable) is turned off only by one of these; any other value, absent
250
+ * included, leaves it on, so an unrelated string never silently hides a command. */
251
+ const YAML_FALSE = new Set(['false', 'no', 'off', 'n', '0'])
252
+ const isFlagDisabled = (value: unknown): boolean => value === false || YAML_FALSE.has(text(value).toLowerCase())
253
+
254
+ /** pi's ThinkingLevel union, the values a command's `effort:` override may name. */
255
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
256
+
239
257
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
240
258
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
241
259
 
@@ -265,6 +283,7 @@ export function parseCommandFile(content: string): ParsedCommand {
265
283
  const disable = frontmatter['disable-model-invocation']
266
284
  const grants = parseToolGrants(frontmatter['allowed-tools'])
267
285
  const shell = text(frontmatter.shell).toLowerCase()
286
+ const effort = text(frontmatter.effort).toLowerCase()
268
287
  return {
269
288
  description: text(frontmatter.description) || firstLine.slice(0, 60),
270
289
  argumentHint: hint(frontmatter['argument-hint']) || undefined,
@@ -276,7 +295,11 @@ export function parseCommandFile(content: string): ParsedCommand {
276
295
  disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
277
296
  shell: SHELLS.has(shell) ? shell : undefined,
278
297
  model: text(frontmatter.model) || undefined,
298
+ // An unrecognized effort is dropped rather than passed to setThinkingLevel.
299
+ effort: THINKING_LEVELS.has(effort) ? effort : undefined,
300
+ whenToUse: text(frontmatter.when_to_use) || undefined,
279
301
  disableModelInvocation: isFlagEnabled(disable),
302
+ userInvocable: !isFlagDisabled(frontmatter['user-invocable']),
280
303
  body,
281
304
  }
282
305
  }
@@ -453,7 +476,9 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
453
476
  if (shell === 'powershell') {
454
477
  const binary = resolveBinary()
455
478
  if (binary !== undefined) {
456
- const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'`
479
+ // CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
480
+ // it is exported in the script alongside CLAUDE_PROJECT_DIR.
481
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
457
482
  // No in-script 2>&1: under pwsh 7 it does not merge a native command's
458
483
  // stderr on a script block, so mergeStreams has the caller append it. The
459
484
  // trailing exit forwards a failed native command's code, which pwsh
@@ -470,7 +495,7 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
470
495
  // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
471
496
  // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
472
497
  // while the group still merges stderr for real spans.
473
- return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
498
+ return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
474
499
  }
475
500
 
476
501
  interface FenceBlock {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CLAUDE_CONFIG_DIR: Claude Code's override for the home configuration directory.
3
+ *
4
+ * Claude relocates the entire ~/.claude configuration tree (settings.json, commands,
5
+ * agents, skills, plugins, output-styles, CLAUDE.md) when CLAUDE_CONFIG_DIR is set,
6
+ * so a user can keep that config outside their home directory. This resolves the
7
+ * home-scope config root for every consumer; a project's own `.claude/` directory is
8
+ * a separate scope and is never affected. A leading `~` expands against `home`, and
9
+ * the result is resolved to an absolute path so a relative value cannot depend on the
10
+ * reader's working directory.
11
+ */
12
+
13
+ import * as path from 'node:path'
14
+
15
+ /** The home-scope Claude config directory: CLAUDE_CONFIG_DIR (expanded, absolute)
16
+ * when set to a non-empty value, otherwise `<home>/.claude`. */
17
+ export function claudeConfigDir(home: string): string {
18
+ const override = process.env.CLAUDE_CONFIG_DIR
19
+ if (override && override.trim().length > 0) {
20
+ const expanded = override.startsWith('~') ? path.join(home, override.slice(1)) : override
21
+ return path.resolve(expanded)
22
+ }
23
+ return path.join(home, '.claude')
24
+ }
@@ -132,6 +132,51 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
132
132
  return path.join(anchors.cwd, rel)
133
133
  }
134
134
 
135
+ /** One rule glob precompiled for repeated matching: its anchored regex, and whether
136
+ * it applies to the basename (a slashless pattern, gitignore-style) or the full
137
+ * root-relative path. */
138
+ export interface CompiledGlob {
139
+ regex: RegExp
140
+ matchesBasename: boolean
141
+ }
142
+
143
+ let globsCompiled = 0
144
+ let globsEvaluated = 0
145
+
146
+ /** Test seam: cumulative compiled-glob work, for asserting that callers compile
147
+ * each glob once upfront and stop evaluating rules that no longer apply. */
148
+ export function globCompileStats(): { compiled: number; evaluated: number } {
149
+ return { compiled: globsCompiled, evaluated: globsEvaluated }
150
+ }
151
+
152
+ /** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
153
+ * pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
154
+ * slash scopes to the directory's contents, and blank entries drop out. */
155
+ export function compileGlobs(globs: string[]): CompiledGlob[] {
156
+ const compiled: CompiledGlob[] = []
157
+ for (const raw of globs) {
158
+ let glob = raw.trim()
159
+ if (!glob) continue
160
+ if (glob.startsWith('./')) glob = glob.slice(2)
161
+ else if (glob.startsWith('/')) glob = glob.slice(1)
162
+ // A trailing slash means the directory's contents, like gitignore; `docs/` alone
163
+ // would compile to `^docs/$` and match nothing.
164
+ if (glob.endsWith('/')) glob += '**'
165
+ globsCompiled += 1
166
+ compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`), matchesBasename: !glob.includes('/') })
167
+ }
168
+ return compiled
169
+ }
170
+
171
+ /** Whether a root-relative path matches at least one compiled glob. No globs means
172
+ * no match. */
173
+ export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): boolean {
174
+ globsEvaluated += 1
175
+ const posix = relPath.split(path.sep).join('/')
176
+ const base = posix.split('/').pop() ?? posix
177
+ return globs.some((glob) => glob.regex.test(glob.matchesBasename ? base : posix))
178
+ }
179
+
135
180
  /** Whether the accessed file matches at least one rule. No rules means no match:
136
181
  * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
137
182
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
@@ -15,6 +15,8 @@
15
15
  import * as fs from 'node:fs'
16
16
  import * as path from 'node:path'
17
17
 
18
+ import { claudeConfigDir } from './config-dir.js'
19
+
18
20
  export interface InstalledPlugin {
19
21
  name: string
20
22
  /** The version directory: ${CLAUDE_PLUGIN_ROOT}. */
@@ -90,16 +92,70 @@ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string
90
92
  return merged
91
93
  }
92
94
 
95
+ /** Memoized discovery per (home, extra settings files), revalidated by fingerprint. */
96
+ const pluginCache = new Map<string, { fingerprint: string; plugins: InstalledPlugin[] }>()
97
+
98
+ /** Drop every memoized discovery; the next installedPlugins call walks afresh. */
99
+ export function resetInstalledPluginsCache(): void {
100
+ pluginCache.clear()
101
+ }
102
+
103
+ /** mtime plus size, so a same-instant rewrite with different content still differs. */
104
+ function statToken(target: string): string {
105
+ try {
106
+ const stat = fs.statSync(target)
107
+ return `${stat.mtimeMs}:${stat.size}`
108
+ } catch {
109
+ return 'missing'
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A cheap change signature for one home's plugin config: the settings files' stat
115
+ * tokens plus the cache tree's directory names and mtimes down through each plugin's
116
+ * version directories, and the stat token of the resolved (newest) version's manifest
117
+ * so an in-place edit of it invalidates the cache. Costs a few stats where the full
118
+ * walk reads and parses the settings and every manifest.
119
+ */
120
+ function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
121
+ const parts = settingsFiles.map(statToken)
122
+ for (const marketplace of listDirs(cacheDir)) {
123
+ const marketplaceDir = path.join(cacheDir, marketplace)
124
+ parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
125
+ for (const pluginDir of listDirs(marketplaceDir)) {
126
+ const pluginPath = path.join(marketplaceDir, pluginDir)
127
+ parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
128
+ const versions = listDirs(pluginPath)
129
+ for (const version of versions) {
130
+ parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
131
+ }
132
+ // resolvePlugin reads only the newest version's manifest, so its stat token is
133
+ // what an in-place edit (no directory entry changing) must move.
134
+ const newest = newestVersion(versions)
135
+ if (newest) parts.push(`${marketplace}/${pluginDir}/${newest}/manifest:${statToken(path.join(pluginPath, newest, '.claude-plugin', 'plugin.json'))}`)
136
+ }
137
+ }
138
+ return parts.join('\n')
139
+ }
140
+
93
141
  /**
94
142
  * Enabled plugins from the cache. Enablement is decided by the user's own
95
143
  * settings only: plugins install to the user's machine and carry code (hook
96
144
  * scripts, MCP server commands), so a checked-out repo must not be able to flip
97
145
  * which of them run. `extraSettingsFiles`, when given, are additional
98
146
  * user-controlled settings sources, not project files.
147
+ *
148
+ * Several extensions call this at session start and per discovery, so the walk
149
+ * is memoized behind the fingerprint above; callers always see current data
150
+ * because any settings edit or cache-tree change invalidates it.
99
151
  */
100
152
  export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
101
- const cacheDir = path.join(home, '.claude', 'plugins', 'cache')
102
- const settingsFiles = [path.join(home, '.claude', 'settings.json'), ...extraSettingsFiles]
153
+ const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
154
+ const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
155
+ const key = [home, ...extraSettingsFiles].join('\n')
156
+ const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
157
+ const cached = pluginCache.get(key)
158
+ if (cached?.fingerprint === fingerprint) return cached.plugins
103
159
  const enabled = enabledMap(settingsFiles)
104
160
  const configs = pluginConfigsMap(settingsFiles)
105
161
  const plugins: InstalledPlugin[] = []
@@ -109,6 +165,7 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
109
165
  if (plugin) plugins.push(plugin)
110
166
  }
111
167
  }
168
+ pluginCache.set(key, { fingerprint, plugins })
112
169
  return plugins
113
170
  }
114
171
 
@@ -125,7 +182,7 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
125
182
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
126
183
  const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
127
184
  const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
128
- return { name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
185
+ return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
129
186
  }
130
187
 
131
188
  /** The two plugin path variables, textually substituted into plugin-shipped