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.
- package/extensions/claude-rules.ts +31 -28
- package/extensions/commands.ts +24 -5
- package/extensions/context-imports.ts +92 -17
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +56 -9
- package/extensions/init.ts +3 -1
- package/extensions/internal/model-complete.ts +6 -4
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +55 -0
- package/extensions/mcp.ts +44 -19
- package/extensions/memory.ts +74 -31
- package/extensions/notify.ts +5 -0
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +56 -23
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/index.ts +17 -5
- package/extensions/todo.ts +11 -1
- package/extensions/web.ts +12 -7
- 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
|
})
|
package/extensions/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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;
|
package/extensions/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|