pi-code 1.0.8 → 1.0.9

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.
@@ -22,7 +22,7 @@ import * as path from 'node:path'
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
23
 
24
24
  import { publishInstructionLoad } from './internal/instruction-events.js'
25
- import { globToRegExpSource } from './internal/path-rules.js'
25
+ import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
26
26
  import { isProjectApproved } from './internal/project-approval.js'
27
27
  import { findNearestDir } from './internal/project-root.js'
28
28
  import { stripBlockComments } from './internal/strip-comments.js'
@@ -81,19 +81,7 @@ export function parseFrontmatter(content: string): Frontmatter {
81
81
  * the rule set's root.
82
82
  */
83
83
  export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
84
- const posix = relPath.split(path.sep).join('/')
85
- const base = posix.split('/').pop() ?? posix
86
- return globs.some((raw) => {
87
- let glob = raw.trim()
88
- if (!glob) return false
89
- if (glob.startsWith('./')) glob = glob.slice(2)
90
- else if (glob.startsWith('/')) glob = glob.slice(1)
91
- // A trailing slash means the directory's contents, like gitignore; `docs/` alone
92
- // would compile to `^docs/$` and match nothing.
93
- if (glob.endsWith('/')) glob += '**'
94
- const target = glob.includes('/') ? posix : base
95
- return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
96
- })
84
+ return matchesCompiledGlobs(relPath, compileGlobs(globs))
97
85
  }
98
86
 
99
87
  /** A rule pointer line, annotated with its path scope when present. */
@@ -199,8 +187,10 @@ function rulesSection(title: string, rules: RuleSet, base: string): string {
199
187
 
200
188
  /** A scoped rule resolved to the root its globs match against, ready to attach. */
201
189
  interface AttachTarget {
202
- key: string
190
+ /** The rule's `paths:` globs as written, reported on the instruction-events bus. */
203
191
  globs: string[]
192
+ /** The globs precompiled once at session start for the per-tool-result scan. */
193
+ compiled: CompiledGlob[]
204
194
  body: string
205
195
  /** The absolute directory `paths:` globs are matched relative to. */
206
196
  root: string
@@ -210,6 +200,15 @@ interface AttachTarget {
210
200
  memoryType: 'User' | 'Project'
211
201
  }
212
202
 
203
+ // Module level because the working list lives in each extension instance's closure.
204
+ let pendingScopedRules = 0
205
+
206
+ /** Test seam: scoped rules still awaiting attachment in the current session, for
207
+ * asserting that a fully attached rule leaves the per-tool-result working list. */
208
+ export function pendingScopedRuleCount(): number {
209
+ return pendingScopedRules
210
+ }
211
+
213
212
  export default function claudeRulesExtension(pi: ExtensionAPI) {
214
213
  const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
215
214
  let globalRules: RuleSet = EMPTY_RULES
@@ -218,10 +217,9 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
218
217
  // The project rules dir may sit at an ancestor of cwd, where a cwd-relative
219
218
  // '.claude/rules' would point the read at a path that does not exist.
220
219
  let projectRulesBase = '.claude/rules'
221
- // Scoped rules ready to attach when a matching file is touched, and the set of
222
- // rules already attached this session so each attaches at most once.
220
+ // Scoped rules still awaiting a matching touch. An attached rule leaves the
221
+ // list, so each attaches at most once and the per-tool-result scan shrinks.
223
222
  let attachTargets: AttachTarget[] = []
224
- const attached = new Set<string>()
225
223
 
226
224
  pi.on('session_start', async (_event, ctx) => {
227
225
  globalRules = readRules(globalRulesDir)
@@ -236,13 +234,14 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
236
234
 
237
235
  // Global globs are relative to cwd; project globs to the project root (the dir
238
236
  // holding .claude), so `db/**` in a repo rule matches repo-relative paths even
239
- // from a subdirectory session. Reset per session so a re-run re-attaches.
240
- attached.clear()
237
+ // from a subdirectory session. Globs compile here, once per session, rather
238
+ // than on every tool result; rebuilt per session so a re-run re-attaches.
241
239
  const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
242
240
  attachTargets = [
243
- ...globalRules.scoped.map((rule) => ({ key: `global:${rule.rel}`, globs: rule.paths, body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
244
- ...projectRules.scoped.map((rule) => ({ key: `project:${rule.rel}`, globs: rule.paths, body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
241
+ ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
242
+ ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
245
243
  ]
244
+ pendingScopedRules = attachTargets.length
246
245
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
247
246
  // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
248
247
  // would point at a nonexistent path under the subdirectory.
@@ -277,21 +276,25 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
277
276
  const abs = path.resolve(ctx.cwd, rel)
278
277
 
279
278
  const bodies: string[] = []
279
+ const remaining: AttachTarget[] = []
280
280
  for (const target of attachTargets) {
281
- if (attached.has(target.key)) continue
282
281
  const relativeToRoot = path.relative(target.root, abs)
283
282
  // A file outside the rule root cannot match its project-relative globs. Test for
284
283
  // a real parent-traversal segment, not a leading '..' (a file named `..config` is
285
284
  // inside the root).
286
- if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) continue
287
- if (!pathMatchesGlobs(relativeToRoot, target.globs)) continue
288
- attached.add(target.key)
285
+ const outsideRoot = relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)
286
+ if (outsideRoot || !matchesCompiledGlobs(relativeToRoot, target.compiled)) {
287
+ remaining.push(target)
288
+ continue
289
+ }
289
290
  bodies.push(target.body)
290
291
  // The lazy attach is Claude's path_glob_match instruction load; the hooks
291
- // extension bridges the bus event to the InstructionsLoaded hook. The
292
- // once-per-session attach set above also bounds the events to one per rule.
292
+ // extension bridges the bus event to the InstructionsLoaded hook. Leaving
293
+ // the working list also bounds the events to one per rule per session.
293
294
  publishInstructionLoad(pi.events, { file_path: target.file, memory_type: target.memoryType, load_reason: 'path_glob_match', globs: target.globs, trigger_file_path: abs })
294
295
  }
296
+ attachTargets = remaining
297
+ pendingScopedRules = attachTargets.length
295
298
  if (bodies.length === 0) return
296
299
  return { content: [...event.content, ...bodies.map((text) => ({ type: 'text' as const, text }))] }
297
300
  })
@@ -52,6 +52,7 @@
52
52
  * Docs: https://code.claude.com/docs/en/memory.md (imports)
53
53
  */
54
54
 
55
+ import { createHash } from 'node:crypto'
55
56
  import * as fs from 'node:fs'
56
57
  import * as os from 'node:os'
57
58
  import * as path from 'node:path'
@@ -507,6 +508,81 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
507
508
  publishInstructionLoad(pi.events, event)
508
509
  }
509
510
 
511
+ // before_agent_start fires every turn, but its inputs almost never change
512
+ // mid-session. The settings-derived environment (managed settings, exclude
513
+ // globs, repo root) is cached per cwd, and the whole import expansion is
514
+ // memoized on its inputs and revalidated by a stat token (mtime and size): a
515
+ // turn where nothing changed costs a handful of stats instead of re-reading and
516
+ // re-recursing every @import. A brand-new file satisfying a previously missing
517
+ // @import is picked up when any recorded stat token moves (or next session),
518
+ // which is already fresher than Claude, which loads context once at session start.
519
+ let envCache: { cwd: string; managed: Record<string, unknown>; excludeGlobs: string[]; projectRoot: string } | undefined
520
+ let importMemo:
521
+ | {
522
+ key: string
523
+ extras: Array<{ path: string; content: string; dir: string }>
524
+ imported: ImportedFile[]
525
+ budget: ImportBudget
526
+ tokens: Array<[string, string]>
527
+ }
528
+ | undefined
529
+ // mtime plus size, so a same-mtime rewrite of a different length still invalidates.
530
+ const statToken = (file: string): string => {
531
+ const stat = fs.statSync(file)
532
+ return `${stat.mtimeMs}:${stat.size}`
533
+ }
534
+ const memoIsFresh = (memo: NonNullable<typeof importMemo>): boolean => {
535
+ try {
536
+ return memo.tokens.every(([file, token]) => statToken(file) === token)
537
+ } catch {
538
+ return false // a recorded file vanished: re-expand
539
+ }
540
+ }
541
+ // Memo lookup or recompute: a turn whose key matches the previous expansion and
542
+ // whose recorded stat tokens are all unchanged reuses that expansion outright;
543
+ // otherwise the imports are re-expanded and the memo (with a fresh revalidation
544
+ // set) is rebuilt for next turn.
545
+ const resolveImports = (
546
+ memoKey: string,
547
+ native: Array<{ path: string; content: string }>,
548
+ contextFiles: Array<{ path: string; content: string }>,
549
+ home: string,
550
+ cwd: string,
551
+ excluded: (absPath: string) => boolean,
552
+ ): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
553
+ if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
554
+ const { extras, budget, imported } = importMemo
555
+ return { extras, budget, imported }
556
+ }
557
+ // Seed with every loaded context file path, excluded ones included, so pi's own
558
+ // files are never re-imported and an excluded file cannot return as an import.
559
+ const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
560
+
561
+ // Claude's --add-dir memory loading, env-gated. The files join the seen set
562
+ // before import expansion so an @import cannot pull one in twice, and they get
563
+ // the same exclude and comment-strip treatment as native context files.
564
+ const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
565
+ const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
566
+
567
+ // One budget for the whole run, so N context files cannot each spend a full one.
568
+ // Exclusion applies inside the recursion: an excluded @import is skipped before
569
+ // it is read, so its transitive imports never load and it spends no budget.
570
+ const budget = createImportBudget()
571
+ const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
572
+
573
+ // Revalidation set: every file the expansion read, plus each add-dir itself
574
+ // (a directory's mtime moves when a memory file is added or removed there).
575
+ const tokens: Array<[string, string]> = []
576
+ try {
577
+ for (const file of [...extras.map((extra) => extra.path), ...imported.map((entry) => entry.path)]) tokens.push([file, statToken(file)])
578
+ for (const dir of addDirs) tokens.push([dir, statToken(dir)])
579
+ importMemo = { key: memoKey, extras, budget, imported, tokens }
580
+ } catch {
581
+ importMemo = undefined // a file moved mid-expansion: just recompute next turn
582
+ }
583
+ return { extras, budget, imported }
584
+ }
585
+
510
586
  // Claude's --add-dir. Only the memory-loading half is meaningful here: pi has
511
587
  // no path-based permission system, so there is no access grant to mirror.
512
588
  // Optional-called so the extension still wires under stub hosts without flags.
@@ -520,6 +596,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
520
596
  // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
521
597
  // which is fine, since a reload re-loads the instruction files.
522
598
  announced.clear()
599
+ envCache = undefined
600
+ importMemo = undefined
523
601
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
524
602
  // skips it. A cloned repo can ship one, so it is gated like other project config.
525
603
  // Claude loads local context from the whole hierarchy above the working
@@ -545,10 +623,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
545
623
  const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
546
624
  const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
547
625
 
548
- const managed = readManagedSettings()
549
- const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managed)
626
+ if (envCache?.cwd !== cwd) {
627
+ const managedNow = readManagedSettings()
628
+ envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
629
+ }
630
+ const { managed, excludeGlobs, projectRoot } = envCache
550
631
  const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
551
- const projectRoot = repoRoot(cwd) ?? cwd
552
632
 
553
633
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
554
634
  // from import expansion; surviving blocks get block-level comments stripped.
@@ -575,21 +655,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
575
655
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
576
656
  const contextFiles = [...rewrite.kept, ...keptLocals]
577
657
 
578
- // Seed with every loaded context file path, excluded ones included, so pi's own
579
- // files are never re-imported and an excluded file cannot return as an import.
580
- const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
658
+ // Everything the expansion depends on, hashed: a turn whose inputs match the memo
659
+ // and whose recorded mtimes are unchanged reuses the previous expansion outright.
660
+ const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
661
+ const keyHash = createHash('sha256')
662
+ keyHash.update(`${cwd}\0${home}\0${projectApproved}\0${addDirsRaw}\0${excludeGlobs.join(',')}\0`)
663
+ for (const file of [...native, ...localContexts]) keyHash.update(`${file.path}\0`)
664
+ for (const file of contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
665
+ const memoKey = keyHash.digest('hex')
581
666
 
582
- // Claude's --add-dir memory loading, env-gated. The files join the seen set
583
- // before import expansion so an @import cannot pull one in twice, and they get
584
- // the same exclude and comment-strip treatment as native context files.
585
- const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
586
- const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
587
-
588
- // One budget for the whole run, so N context files cannot each spend a full one.
589
- // Exclusion applies inside the recursion: an excluded @import is skipped before
590
- // it is read, so its transitive imports never load and it spends no budget.
591
- const budget = createImportBudget()
592
- const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
667
+ const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
593
668
 
594
669
  let addition = localContextAddition(keptLocals, announce)
595
670
  addition += additionalDirsAddition(extras, announce)
@@ -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
 
@@ -260,16 +260,24 @@ function foldName(name: string): string {
260
260
  return name.toLowerCase().replaceAll('-', '_')
261
261
  }
262
262
 
263
- function exactListApplies(matcher: string, names: readonly string[]): boolean {
264
- const tokens = new Set(
263
+ /** A matcher string's compiled form: a set of folded exact names, or a regex. */
264
+ type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
265
+
266
+ function exactTokens(matcher: string): Set<string> {
267
+ return new Set(
265
268
  matcher
266
269
  .split(/[|,]/)
267
270
  .map((token) => foldName(token.trim()))
268
271
  .filter(Boolean),
269
272
  )
270
- return names.some((name) => tokens.has(foldName(name)))
271
273
  }
272
274
 
275
+ /** Hook config is static per session and dispatch consults every matcher on every
276
+ * event, so each matcher string compiles once. Matchers are few; the bound is a
277
+ * safety net, clearing the (cheap to rebuild) cache rather than evicting. */
278
+ const compiledMatchers = new Map<string, CompiledMatcher>()
279
+ const COMPILED_MATCHER_BOUND = 1000
280
+
273
281
  /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
274
282
  * is reported by name and skipped, so one bad entry costs its own hooks, not the
275
283
  * session's tool calls. */
@@ -290,15 +298,48 @@ function isUsableMatcher(entry: unknown, file: string, event: string): entry is
290
298
  return true
291
299
  }
292
300
 
301
+ let matcherCompiles = 0
302
+
303
+ /** Test seam: matcher compilations performed, for asserting memoization. */
304
+ export function matcherCompileCount(): number {
305
+ return matcherCompiles
306
+ }
307
+
308
+ /** Test seam: drop compiled matchers so a test observes fresh compiles. */
309
+ export function resetMatcherCache(): void {
310
+ compiledMatchers.clear()
311
+ matcherCompiles = 0
312
+ }
313
+
314
+ function compileMatcher(matcher: string): CompiledMatcher {
315
+ const cached = compiledMatchers.get(matcher)
316
+ if (cached !== undefined) return cached
317
+ matcherCompiles += 1
318
+ let compiled: CompiledMatcher
319
+ if (EXACT_MATCHER.test(matcher)) {
320
+ compiled = { tokens: exactTokens(matcher) }
321
+ } else {
322
+ try {
323
+ compiled = { regex: new RegExp(matcher, 'i') }
324
+ } catch {
325
+ // An invalid regex matcher falls back to exact-name matching, as before.
326
+ compiled = { tokens: exactTokens(matcher) }
327
+ }
328
+ }
329
+ if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
330
+ compiledMatchers.set(matcher, compiled)
331
+ return compiled
332
+ }
333
+
293
334
  function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
294
335
  if (!matcher || matcher === '*') return true
295
- if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
296
- try {
297
- const regex = new RegExp(matcher, 'i')
336
+ const compiled = compileMatcher(matcher)
337
+ if ('regex' in compiled) {
338
+ const { regex } = compiled
298
339
  return names.some((name) => regex.test(name))
299
- } catch {
300
- return exactListApplies(matcher, names)
301
340
  }
341
+ const { tokens } = compiled
342
+ return names.some((name) => tokens.has(foldName(name)))
302
343
  }
303
344
 
304
345
  /** A hook entry pi-code can run: a shell command, an http POST, an in-process
@@ -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 {
@@ -90,16 +90,70 @@ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string
90
90
  return merged
91
91
  }
92
92
 
93
+ /** Memoized discovery per (home, extra settings files), revalidated by fingerprint. */
94
+ const pluginCache = new Map<string, { fingerprint: string; plugins: InstalledPlugin[] }>()
95
+
96
+ /** Drop every memoized discovery; the next installedPlugins call walks afresh. */
97
+ export function resetInstalledPluginsCache(): void {
98
+ pluginCache.clear()
99
+ }
100
+
101
+ /** mtime plus size, so a same-instant rewrite with different content still differs. */
102
+ function statToken(target: string): string {
103
+ try {
104
+ const stat = fs.statSync(target)
105
+ return `${stat.mtimeMs}:${stat.size}`
106
+ } catch {
107
+ return 'missing'
108
+ }
109
+ }
110
+
111
+ /**
112
+ * A cheap change signature for one home's plugin config: the settings files' stat
113
+ * tokens plus the cache tree's directory names and mtimes down through each plugin's
114
+ * version directories, and the stat token of the resolved (newest) version's manifest
115
+ * so an in-place edit of it invalidates the cache. Costs a few stats where the full
116
+ * walk reads and parses the settings and every manifest.
117
+ */
118
+ function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
119
+ const parts = settingsFiles.map(statToken)
120
+ for (const marketplace of listDirs(cacheDir)) {
121
+ const marketplaceDir = path.join(cacheDir, marketplace)
122
+ parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
123
+ for (const pluginDir of listDirs(marketplaceDir)) {
124
+ const pluginPath = path.join(marketplaceDir, pluginDir)
125
+ parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
126
+ const versions = listDirs(pluginPath)
127
+ for (const version of versions) {
128
+ parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
129
+ }
130
+ // resolvePlugin reads only the newest version's manifest, so its stat token is
131
+ // what an in-place edit (no directory entry changing) must move.
132
+ const newest = newestVersion(versions)
133
+ if (newest) parts.push(`${marketplace}/${pluginDir}/${newest}/manifest:${statToken(path.join(pluginPath, newest, '.claude-plugin', 'plugin.json'))}`)
134
+ }
135
+ }
136
+ return parts.join('\n')
137
+ }
138
+
93
139
  /**
94
140
  * Enabled plugins from the cache. Enablement is decided by the user's own
95
141
  * settings only: plugins install to the user's machine and carry code (hook
96
142
  * scripts, MCP server commands), so a checked-out repo must not be able to flip
97
143
  * which of them run. `extraSettingsFiles`, when given, are additional
98
144
  * user-controlled settings sources, not project files.
145
+ *
146
+ * Several extensions call this at session start and per discovery, so the walk
147
+ * is memoized behind the fingerprint above; callers always see current data
148
+ * because any settings edit or cache-tree change invalidates it.
99
149
  */
100
150
  export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
101
151
  const cacheDir = path.join(home, '.claude', 'plugins', 'cache')
102
152
  const settingsFiles = [path.join(home, '.claude', 'settings.json'), ...extraSettingsFiles]
153
+ const key = [home, ...extraSettingsFiles].join('\n')
154
+ const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
155
+ const cached = pluginCache.get(key)
156
+ if (cached?.fingerprint === fingerprint) return cached.plugins
103
157
  const enabled = enabledMap(settingsFiles)
104
158
  const configs = pluginConfigsMap(settingsFiles)
105
159
  const plugins: InstalledPlugin[] = []
@@ -109,6 +163,7 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
109
163
  if (plugin) plugins.push(plugin)
110
164
  }
111
165
  }
166
+ pluginCache.set(key, { fingerprint, plugins })
112
167
  return plugins
113
168
  }
114
169
 
package/extensions/mcp.ts CHANGED
@@ -649,6 +649,23 @@ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport //
649
649
 
650
650
  type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
651
651
 
652
+ /** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
653
+ * at once (a user-scope and a consented project-scope server both 401ing, connecting in
654
+ * parallel) would stack dialogs and browser tabs. This chains them so a second
655
+ * interactive login waits for the first to settle; the tail is reset to a resolved
656
+ * promise regardless of outcome, so a failed login never poisons the queue. Silent
657
+ * (stored-token) connects do not pass through here and stay fully parallel. */
658
+ let oauthQueue: Promise<unknown> = Promise.resolve()
659
+
660
+ function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
661
+ const result = oauthQueue.then(run, run)
662
+ oauthQueue = result.then(
663
+ () => {},
664
+ () => {},
665
+ )
666
+ return result
667
+ }
668
+
652
669
  /**
653
670
  * Connect an http-family server, running Claude's OAuth login when the server
654
671
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
@@ -671,7 +688,7 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
671
688
  } catch (error) {
672
689
  if (bearerToken || !isUnauthorized(error)) throw error
673
690
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
674
- return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
691
+ return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
675
692
  }
676
693
  }
677
694
 
@@ -1113,17 +1130,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1113
1130
  )
1114
1131
  }
1115
1132
 
1116
- /** Connect the project scope under the per-server policy. Returns whether the scope
1117
- * is settled, so a refused confirm can be retried on a later session start. */
1118
- async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
1119
- // The stored decision, read without prompting: consent recorded inside the
1120
- // project only counts once the project itself has been approved.
1121
- const approved = isProjectApprovedSilently(ctx)
1122
- const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
1123
- const { allowed, denied } = mcpAllowDeny()
1124
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
1125
- const authUi = authUiFor(ctx)
1126
- if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
1133
+ /** Connect the approval-gated project servers, behind the whole-project confirm.
1134
+ * Returns whether the scope is settled, so a refused confirm can be retried on a
1135
+ * later session start. The consented half of the project scope connects earlier,
1136
+ * concurrently with the user scope, from session_start itself. */
1137
+ async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
1127
1138
  if (Object.keys(gated).length === 0) return true
1128
1139
  if (!(await isProjectApproved(ctx))) return false
1129
1140
  await connectServers(gated, authUi)
@@ -1148,16 +1159,28 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1148
1159
  // entry cannot shadow a trusted user server by reusing its name. A gated project
1149
1160
  // server still awaiting the approval prompt does not preempt the user server: that is
1150
1161
  // a deliberate narrowing of Claude's rule to keep the safe default.
1162
+ // The stored project decision, read without prompting: consent recorded inside
1163
+ // the project only counts once the project itself has been approved.
1151
1164
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1152
- const projectWinners = new Set(Object.keys(splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy).consented))
1165
+ const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
1166
+ const projectWinners = new Set(Object.keys(consented))
1153
1167
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
1154
- if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
1168
+ const authUi = authUiFor(ctx)
1169
+ // The consented project servers carry no ordering dependency on the user scope:
1170
+ // projectWinners already excludes their names from userServers, so the two batches
1171
+ // are disjoint and connect concurrently, and startup pays the slower scope rather
1172
+ // than the sum of both. Reconnect attempts after a refused confirm are safe:
1173
+ // connectServers skips names that already connected.
1174
+ const connects: Promise<void>[] = []
1175
+ if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
1176
+ if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
1177
+ await Promise.all(connects)
1155
1178
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
1156
1179
  // the project is trusted. Per-server settings refine that: disabled servers never
1157
- // connect, servers the user consented to individually connect without the
1158
- // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
1159
- // refusal are safe: connectServers skips names that already connected.
1160
- if (!projectConnected) projectConnected = await connectProjectScope(ctx)
1180
+ // connect, servers the user consented to individually connected above without the
1181
+ // whole-project confirm, and the rest stay behind it, sequentially after both
1182
+ // scopes so the confirm dialog never races a connect.
1183
+ if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
1161
1184
 
1162
1185
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1163
1186
 
@@ -310,6 +310,27 @@ export default function memoryExtension(pi: ExtensionAPI) {
310
310
  let dir = memoryDir(process.cwd())
311
311
  let enabled = true
312
312
 
313
+ // The index is injected every turn but changes only through the tool or an external
314
+ // edit, so a turn costs one stat instead of a full read. The stat token (mtime plus
315
+ // size) catches external edits; save and delete drop the cache outright, since a
316
+ // rename landing within one mtime tick at the same size would slip past the token.
317
+ let indexCache: { token: string; index: string } | null = null
318
+
319
+ const indexStatToken = (): string => {
320
+ try {
321
+ const stat = fs.statSync(path.join(dir, INDEX_FILE))
322
+ return `${stat.mtimeMs}:${stat.size}`
323
+ } catch {
324
+ return 'missing'
325
+ }
326
+ }
327
+
328
+ const readIndexCached = (): string => {
329
+ const token = indexStatToken()
330
+ if (indexCache?.token !== token) indexCache = { token, index: readIndexQuietly(dir) }
331
+ return indexCache.index
332
+ }
333
+
313
334
  // These extensions also load inside spawned subagent processes, which carry the
314
335
  // PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
315
336
  // into subagents (they get their own store through the agent `memory:` field), so
@@ -325,6 +346,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
325
346
  enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
326
347
  const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
327
348
  dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
349
+ indexCache = null
328
350
  if (!enabled) return
329
351
  const count = readIndexQuietly(dir)
330
352
  .split('\n')
@@ -334,7 +356,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
334
356
 
335
357
  pi.on('before_agent_start', async (event) => {
336
358
  if (inSubagent() || !enabled) return
337
- const index = readIndexQuietly(dir)
359
+ const index = readIndexCached()
338
360
  if (!index.trim()) return
339
361
  return {
340
362
  systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
@@ -362,6 +384,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
362
384
  return await saveMemory(dir, indexPath, name, params.description, params.content)
363
385
  } catch (error) {
364
386
  return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
387
+ } finally {
388
+ indexCache = null
365
389
  }
366
390
  }
367
391
 
@@ -372,7 +396,13 @@ export default function memoryExtension(pi: ExtensionAPI) {
372
396
 
373
397
  if (params.action === 'delete') {
374
398
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
375
- return deleteMemory(dir, indexPath, name)
399
+ // In a finally like the save path: a delete that throws mid-write must still
400
+ // drop the cache, or the next turn injects a stale index.
401
+ try {
402
+ return await deleteMemory(dir, indexPath, name)
403
+ } finally {
404
+ indexCache = null
405
+ }
376
406
  }
377
407
 
378
408
  const index = readIndexQuietly(dir)
@@ -13,8 +13,10 @@
13
13
  * the built-in segment stands in.
14
14
  *
15
15
  * Without a configured statusLine, the built-in segment shows turn state plus
16
- * running session cost, summed from per-message usage on the current branch so it
17
- * stays correct across /tree navigation and forks. The built-in segment is also
16
+ * running session cost: a total seeded from the branch's per-message usage at
17
+ * session start, accumulated per message_end, and reseeded when compaction or
18
+ * /tree navigation reshapes the branch, so it stays correct across navigation
19
+ * and forks without re-walking the branch on every render. The built-in segment is also
18
20
  * the fallback while a configured command produces no output. Multi-line output
19
21
  * is truncated to its first line: the segment is one footer row in pi.
20
22
  *
@@ -48,6 +50,8 @@ interface UsageEntry {
48
50
  message?: { usage?: { cost?: { total?: number } } }
49
51
  }
50
52
 
53
+ /** Full branch walk: used only to (re)seed the running total, at session start
54
+ * and on the events that reshape the branch. Renders read the total instead. */
51
55
  function sessionCost(ctx: ExtensionContext): number {
52
56
  let total = 0
53
57
  for (const entry of ctx.sessionManager.getBranch() as UsageEntry[]) {
@@ -95,8 +99,17 @@ export default function statusLine(pi: ExtensionAPI) {
95
99
  let sessionCtx: ExtensionContext | undefined
96
100
  let commandLine: string | undefined
97
101
  let permissionMode = 'default'
98
- let projectApproved = false
99
102
  let sessionStartMs = Date.now()
103
+ // Running session cost; seeded and reseeded by sessionCost(), see below.
104
+ let costTotal = 0
105
+ // The output-style settings chain and active style name, resolved once at
106
+ // session start: the chain's upward walk and per-file reads are too costly for
107
+ // every refresh tick. /output-style persists a choice straight to settings with
108
+ // no bus event, and the new style applies from the next turn anyway, so the
109
+ // cached name is re-read lazily at most once per turn (styleDirty, turn_start).
110
+ let styleFiles: string[] = []
111
+ let styleName: string | undefined
112
+ let styleDirty = false
100
113
  // Lines changed, counted from successful edit/write inputs: newText and content
101
114
  // lines add, oldText lines remove. An approximation of Claude's counters, which
102
115
  // is honest for the tools pi has; bash-side changes are invisible to both.
@@ -114,8 +127,7 @@ export default function statusLine(pi: ExtensionAPI) {
114
127
 
115
128
  function segmentText(ctx: ExtensionContext, symbol: string): string {
116
129
  const theme = ctx.ui.theme
117
- const cost = sessionCost(ctx)
118
- const costText = cost > 0 ? theme.fg('muted', ` ${formatCost(cost)}`) : ''
130
+ const costText = costTotal > 0 ? theme.fg('muted', ` ${formatCost(costTotal)}`) : ''
119
131
  const turnText = turnCount > 0 ? theme.fg('dim', ` turn ${turnCount}`) : theme.fg('dim', ' ready')
120
132
  return symbol + turnText + costText
121
133
  }
@@ -128,9 +140,11 @@ export default function statusLine(pi: ExtensionAPI) {
128
140
  function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
129
141
  const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
130
142
  const model = ctx.model as { id?: string; name?: string } | undefined
131
- // Same gate as the config read above: an unapproved project's style is not applied,
132
- // so reporting it here would describe a style the session is not using.
133
- const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
143
+ // Refresh the cached style name only when a turn boundary may have changed it.
144
+ if (styleDirty) {
145
+ styleName = readActiveStyleName(styleFiles)
146
+ styleDirty = false
147
+ }
134
148
  const payload: Record<string, unknown> = {
135
149
  hook_event_name: 'Status',
136
150
  session_id: ctx.sessionManager.getSessionId(),
@@ -141,7 +155,7 @@ export default function statusLine(pi: ExtensionAPI) {
141
155
  // read .model.display_name and render the literal "null" when it is missing.
142
156
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
143
157
  cost: {
144
- total_cost_usd: sessionCost(ctx),
158
+ total_cost_usd: costTotal,
145
159
  total_duration_ms: Date.now() - sessionStartMs,
146
160
  total_api_duration_ms: apiDurationMs,
147
161
  total_lines_added: linesAdded,
@@ -250,10 +264,13 @@ export default function statusLine(pi: ExtensionAPI) {
250
264
  if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
251
265
  requestStartMs = undefined
252
266
  })
253
- // The last message's token usage, for the breakdown getContextUsage() omits.
267
+ // The last message's token usage, for the breakdown getContextUsage() omits,
268
+ // and the running cost total, so renders never re-walk the branch.
254
269
  pi.on('message_end', async (event) => {
255
- const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
256
- if (usage) lastUsage = usage
270
+ const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> & { cost?: { total?: number } } } }).message?.usage
271
+ if (!usage) return
272
+ lastUsage = usage
273
+ costTotal += usage.cost?.total ?? 0
257
274
  })
258
275
 
259
276
  pi.on('session_start', async (_event, ctx) => {
@@ -268,11 +285,18 @@ export default function statusLine(pi: ExtensionAPI) {
268
285
  requestStartMs = undefined
269
286
  lastUsage = undefined
270
287
  clearInterval(refreshTimer)
288
+ // Seed the running cost from the branch: a resumed or forked session starts
289
+ // with history, and message_end only accumulates from here on.
290
+ costTotal = sessionCost(ctx)
271
291
  // Reading config must never open a trust dialog: several extensions resolve
272
292
  // approval at session start, and a second prompt stacks over the first and eats
273
293
  // the keys meant for it. An undecided project simply skips project settings.
274
294
  const trusted = isProjectApprovedSilently(ctx)
275
- projectApproved = trusted
295
+ // Same gate for the style chain: an unapproved project's style is not applied,
296
+ // so reporting it in the payload would describe a style the session is not using.
297
+ styleFiles = settingsFiles(ctx.cwd, os.homedir(), trusted)
298
+ styleName = readActiveStyleName(styleFiles)
299
+ styleDirty = false
276
300
  const files = hookFiles(ctx.cwd, os.homedir(), trusted)
277
301
  // Claude's disableAllHooks also turns off the custom statusLine command; the
278
302
  // built-in segment still renders as the fallback.
@@ -286,6 +310,9 @@ export default function statusLine(pi: ExtensionAPI) {
286
310
 
287
311
  pi.on('turn_start', async (_event, ctx) => {
288
312
  turnCount++
313
+ // A /output-style between turns lands in settings silently; its style applies
314
+ // from this turn, so this is the moment the cached name can go stale.
315
+ styleDirty = true
289
316
  const theme = ctx.ui.theme
290
317
  show(ctx, theme.fg('accent', '●') + theme.fg('dim', ` turn ${turnCount}...`))
291
318
  })
@@ -300,10 +327,17 @@ export default function statusLine(pi: ExtensionAPI) {
300
327
  scheduleRefresh()
301
328
  })
302
329
 
303
- pi.on('session_compact', async (_event, _ctx) => {
330
+ pi.on('session_compact', async (_event, ctx) => {
331
+ // Compaction replaces the branch entries; reseed the total from what remains.
332
+ costTotal = sessionCost(ctx)
304
333
  scheduleRefresh()
305
334
  })
306
335
 
336
+ pi.on('session_tree', async (_event, ctx) => {
337
+ // Tree navigation swaps the branch wholesale with no message_end events.
338
+ costTotal = sessionCost(ctx)
339
+ })
340
+
307
341
  pi.on('session_shutdown', async () => {
308
342
  clearInterval(refreshTimer)
309
343
  clearTimeout(debounceTimer)
@@ -1352,7 +1352,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
1352
1352
  // available model list are captured per session so a hook run lands in the right repo.
1353
1353
  let hookCwd = process.cwd()
1354
1354
  let hookModels: ReadonlyArray<{ id: string }> = []
1355
+
1356
+ // Discovery walks the plugin cache, the builtin dir, and every agent dir, parsing
1357
+ // each file: dozens of fs ops per call. The roster injection below runs every turn
1358
+ // for a list that almost never changes mid-session, so it reuses one discovery per
1359
+ // (cwd, scope), dropped on session_start. The tool's execute() keeps rediscovering
1360
+ // per invocation, so a just-added agent is still runnable without a restart.
1361
+ let rosterCache: { key: string; agents: AgentConfig[] } | null = null
1362
+
1355
1363
  pi.on('session_start', async (_event, ctx) => {
1364
+ rosterCache = null
1356
1365
  hookCwd = ctx.cwd
1357
1366
  try {
1358
1367
  hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
@@ -1378,12 +1387,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
1378
1387
  })
1379
1388
 
1380
1389
  // Claude surfaces each agent's description so the model can pick one autonomously.
1381
- // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1382
- // included only when the project is already approved, read without prompting, since
1383
- // a trust dialog must not appear mid-turn and their descriptions are project text.
1390
+ // Served from the session-level cache above (keyed on cwd and scope, so an approval
1391
+ // granted mid-session still widens it); project agents are included only when the
1392
+ // project is already approved, read without prompting, since a trust dialog must
1393
+ // not appear mid-turn and their descriptions are project text.
1384
1394
  pi.on('before_agent_start', async (event, ctx) => {
1385
- const scope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1386
- const { agents } = discoverAgents(ctx.cwd, scope)
1395
+ const scope: AgentScope = isProjectApprovedSilently(ctx) ? 'both' : 'user'
1396
+ const key = `${scope}\n${ctx.cwd}`
1397
+ if (rosterCache?.key !== key) rosterCache = { key, agents: discoverAgents(ctx.cwd, scope).agents }
1398
+ const { agents } = rosterCache
1387
1399
  if (agents.length === 0) return
1388
1400
  const line = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 200)
1389
1401
  const roster = agents.map((agent) => `- ${agent.name} (${agent.source}): ${line(agent.description)}`).join('\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
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, and subagents",
5
5
  "keywords": [
6
6
  "pi",