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.
- package/extensions/claude-rules.ts +31 -28
- package/extensions/context-imports.ts +92 -17
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +49 -8
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +55 -0
- package/extensions/mcp.ts +41 -18
- package/extensions/memory.ts +32 -2
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/index.ts +17 -5
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
//
|
|
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.
|
|
240
|
-
|
|
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) => ({
|
|
244
|
-
...projectRules.scoped.map((rule) => ({
|
|
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
|
-
|
|
287
|
-
if (!
|
|
288
|
-
|
|
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.
|
|
292
|
-
//
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
//
|
|
579
|
-
//
|
|
580
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
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
|
|
package/extensions/hooks.ts
CHANGED
|
@@ -260,16 +260,24 @@ function foldName(name: string): string {
|
|
|
260
260
|
return name.toLowerCase().replaceAll('-', '_')
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
const regex =
|
|
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
|
|
1117
|
-
* is settled, so a refused confirm can be retried on a
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1158
|
-
// whole-project confirm, and the rest stay behind it
|
|
1159
|
-
//
|
|
1160
|
-
if (!projectConnected) projectConnected = await
|
|
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
|
|
package/extensions/memory.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
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
|
|
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.
|
|
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",
|