pi-code 1.0.7 → 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
  })
@@ -46,6 +46,7 @@ import { Type } from 'typebox'
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
47
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
48
  import { readManagedSettings } from './internal/managed-settings.js'
49
+ import { capForContext } from './internal/output-guard.js'
49
50
  import { matchesPathRules } from './internal/path-rules.js'
50
51
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
51
52
  import { isProjectApproved } from './internal/project-approval.js'
@@ -315,12 +316,17 @@ export default function commandsExtension(pi: ExtensionAPI) {
315
316
  pendingBashRules = undefined
316
317
  pendingPathRules = undefined
317
318
  if (pendingModelRestore) {
318
- void pi.setModel(pendingModelRestore as Parameters<typeof pi.setModel>[0])
319
+ const restore = pendingModelRestore as Parameters<typeof pi.setModel>[0]
319
320
  pendingModelRestore = undefined
321
+ // setModel can reject (e.g. auth resolution fails), and a floated rejection would
322
+ // escape as unhandled; surface it instead of leaving the session silently on the
323
+ // command's override model.
324
+ void pi.setModel(restore).catch(() => {})
325
+ }
326
+ if (pendingRestore) {
327
+ pi.setActiveTools(pendingRestore)
328
+ pendingRestore = undefined
320
329
  }
321
- if (!pendingRestore) return
322
- pi.setActiveTools(pendingRestore)
323
- pendingRestore = undefined
324
330
  })
325
331
 
326
332
  // The active-tool set has no argument dimension, so a scoped grant hands the turn
@@ -416,6 +422,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
416
422
  // restored when that run ends. Restoring inline does not work: sendUserMessage is
417
423
  // fire-and-forget, so the restore would land before the agent ever read the tool
418
424
  // list, leaving the command running with everything enabled.
425
+ // A command invoked while the agent is streaming must not narrow the in-flight run's
426
+ // tools or switch its model (that would corrupt a run it does not own), and a bare
427
+ // sendUserMessage throws mid-stream and would be silently dropped. Queue it as a
428
+ // follow-up through pi's own queue, which is abort-aware and shown to the user; its
429
+ // frontmatter scoping is not applied in that case, since it cannot land on a run that
430
+ // has not started yet.
431
+ if (!ctx.isIdle()) {
432
+ pi.sendUserMessage(expanded, { deliverAs: 'followUp' })
433
+ return
434
+ }
419
435
  applyAllowedTools(parsed, vars)
420
436
  applyDisallowedTools(parsed)
421
437
  await applyModelOverride(parsed, varCtx)
@@ -500,7 +516,10 @@ export default function commandsExtension(pi: ExtensionAPI) {
500
516
  // the tool result is the channel, and frontmatter scoping stays user-path
501
517
  // territory (see the header).
502
518
  const expanded = await expandCommand(pi, current, args, execCtx, command.filePath, command.plugin, { allowShell: false })
503
- return { content: [{ type: 'text' as const, text: `Contents of /${name} (expanded):\n\n${expanded}` }], details: {} }
519
+ // Cap the tool result: a command body can inline an arbitrarily large @file, and
520
+ // an uncapped tool result overflows the model's context (every other pi-code tool
521
+ // routes its output through capForContext). The user-invoked path stays uncapped.
522
+ return { content: [{ type: 'text' as const, text: capForContext(`Contents of /${name} (expanded):\n\n${expanded}`) }], details: {} }
504
523
  },
505
524
  })
506
525
  })
@@ -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
@@ -565,7 +606,7 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
565
606
  const prompt = substituteArguments(hook.prompt, payload)
566
607
  const signal = AbortSignal.timeout(timeoutMs)
567
608
  try {
568
- const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
609
+ const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
569
610
  return { code: 0, stdout: answer, stderr: '', timedOut: false }
570
611
  } catch (error) {
571
612
  return abortAwareFailure(signal, error)
@@ -971,6 +1012,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
971
1012
  // turn, and stop_hook_active in the payload tells the next firing it is already
972
1013
  // continuing from a stop hook, which is the hook script's documented loop guard.
973
1014
  // Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
1015
+ //
1016
+ // On agent_end rather than agent_settled: agent_settled is only emitted after every
1017
+ // agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
1018
+ // handler on a UI dialog, which would starve the Stop hook and idle notification
1019
+ // until the user answers it. agent_end can fire slightly early before a rare
1020
+ // automatic retry or compaction; that is the better tradeoff.
974
1021
  pi.on('agent_end', async (event, ctx) => {
975
1022
  // Claude's Notification event, for the one type pi can honestly source: the
976
1023
  // agent finished and is waiting for input (idle_prompt). Observational only;
@@ -75,7 +75,9 @@ export default function initExtension(pi: ExtensionAPI) {
75
75
  const existing = findExistingContextFile(root)
76
76
  const cursorRules = statOf(path.join(root, '.cursor', 'rules'))?.isDirectory() === true || statOf(path.join(root, '.cursorrules'))?.isFile() === true
77
77
  const copilotRules = statOf(path.join(root, '.github', 'copilot-instructions.md'))?.isFile() === true
78
- pi.sendUserMessage(buildInitPrompt({ ...(existing !== undefined ? { existingContextFile: existing } : {}), cursorRules, copilotRules }))
78
+ // A bare send throws (and is silently swallowed) while the agent is
79
+ // streaming, so mid-stream invocations queue as a follow-up turn.
80
+ pi.sendUserMessage(buildInitPrompt({ ...(existing !== undefined ? { existingContextFile: existing } : {}), cursorRules, copilotRules }), ctx.isIdle() ? {} : { deliverAs: 'followUp' })
79
81
  },
80
82
  })
81
83
  }
@@ -14,7 +14,7 @@
14
14
  * throw and the caller falls back to its non-model behavior.
15
15
  */
16
16
 
17
- import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions } from '@earendil-works/pi-ai'
17
+ import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions, Usage } from '@earendil-works/pi-ai'
18
18
  import { ModelRuntime } from '@earendil-works/pi-coding-agent'
19
19
 
20
20
  /** The completion backend: model + context -> assistant message. Overridable for tests. */
@@ -52,11 +52,13 @@ export interface CompleteOptions {
52
52
  }
53
53
 
54
54
  /**
55
- * Run `prompt` through `model` as a single user turn and return the reply text.
55
+ * Run `prompt` through `model` as a single user turn and return the reply text plus
56
+ * the call's usage. A tool that makes a nested LLM call must return that usage on
57
+ * its tool result, or the call's tokens and cost vanish from pi's session totals.
56
58
  * Throws on any failure so the caller can fall back; never returns a partial or a
57
59
  * tool call, only assistant text.
58
60
  */
59
- export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<string> {
61
+ export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<{ text: string; usage: Usage }> {
60
62
  backend ??= realBackend()
61
63
  const complete = await backend
62
64
  const context: Context = {
@@ -64,5 +66,5 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
64
66
  messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
65
67
  }
66
68
  const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
67
- return assistantText(message)
69
+ return { text: assistantText(message), usage: message.usage }
68
70
  }
@@ -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