pi-code 1.0.5 → 1.0.6
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/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/hooks.ts +139 -27
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +13 -7
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +371 -66
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
|
@@ -159,34 +159,60 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
/** Optional controls for a collection run: the byte/file budget shared across the
|
|
163
|
+
* whole run, the path of the file this content came from (seeds each top-level
|
|
164
|
+
* import's `parent`), and the exclusion predicate. Recursion depth is internal. */
|
|
165
|
+
export interface CollectImportsOptions {
|
|
166
|
+
budget?: ImportBudget
|
|
167
|
+
importer?: string
|
|
168
|
+
isExcluded?: (realPath: string) => boolean
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The parts of a collection run that stay fixed across the recursion: resolution
|
|
172
|
+
* roots, the seen/budget accumulators, and the exclusion predicate. Only content,
|
|
173
|
+
* fromDir, depth and the parent path change from one level to the next. */
|
|
174
|
+
interface ImportScan {
|
|
175
|
+
home: string
|
|
176
|
+
allowedRoots: string[]
|
|
177
|
+
seen: Set<string>
|
|
178
|
+
budget: ImportBudget
|
|
179
|
+
isExcluded?: (realPath: string) => boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One recursion level: read the imports named in `content`, then recurse into each. */
|
|
183
|
+
function collectFrom(scan: ImportScan, content: string, fromDir: string, depth: number, importer?: string): ImportedFile[] {
|
|
168
184
|
if (depth >= MAX_IMPORT_DEPTH) return []
|
|
169
185
|
const out: ImportedFile[] = []
|
|
170
186
|
for (const target of importTargets(content)) {
|
|
171
187
|
// Checked before the read so an exhausted budget costs no I/O.
|
|
172
|
-
if (budget.files === 0 || budget.bytes === 0) {
|
|
173
|
-
budget.dropped += 1
|
|
188
|
+
if (scan.budget.files === 0 || scan.budget.bytes === 0) {
|
|
189
|
+
scan.budget.dropped += 1
|
|
174
190
|
continue
|
|
175
191
|
}
|
|
176
|
-
const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
|
|
192
|
+
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
177
193
|
if (!file) continue
|
|
178
|
-
budget.files -= 1
|
|
179
|
-
const kept = file.body.slice(0, budget.bytes)
|
|
180
|
-
budget.bytes -= kept.length
|
|
194
|
+
scan.budget.files -= 1
|
|
195
|
+
const kept = file.body.slice(0, scan.budget.bytes)
|
|
196
|
+
scan.budget.bytes -= kept.length
|
|
181
197
|
const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
|
|
182
198
|
// Comments are stripped before the scan for further imports, so a
|
|
183
199
|
// commented-out @import stays dead at every depth, matching the top level
|
|
184
200
|
// (whose bodies arrive here already stripped by the caller).
|
|
185
|
-
out.push({ path: file.real, body, parent: importer }, ...
|
|
201
|
+
out.push({ path: file.real, body, parent: importer }, ...collectFrom(scan, stripBlockComments(kept), path.dirname(file.real), depth + 1, file.real))
|
|
186
202
|
}
|
|
187
203
|
return out
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Collect the contents of every file transitively imported via `@path`, in
|
|
208
|
+
* discovery order. Imports are resolved through symlinks and kept within
|
|
209
|
+
* `allowedRoots` (which must already be realpath'd).
|
|
210
|
+
*/
|
|
211
|
+
export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, options: CollectImportsOptions = {}): ImportedFile[] {
|
|
212
|
+
const scan: ImportScan = { home, allowedRoots, seen, budget: options.budget ?? createImportBudget(), isExcluded: options.isExcluded }
|
|
213
|
+
return collectFrom(scan, content, fromDir, 0, options.importer)
|
|
214
|
+
}
|
|
215
|
+
|
|
190
216
|
/**
|
|
191
217
|
* Roots an importing file may pull from.
|
|
192
218
|
*
|
|
@@ -357,6 +383,114 @@ export function isExcludedPath(absPath: string, globs: string[], home: string):
|
|
|
357
383
|
})
|
|
358
384
|
}
|
|
359
385
|
|
|
386
|
+
/** Apply claudeMdExcludes and comment-stripping to pi's native context blocks,
|
|
387
|
+
* rewriting the assembled prompt by exact substring: an excluded file's block is
|
|
388
|
+
* removed, a surviving file's block is replaced with its comment-stripped body. A
|
|
389
|
+
* wrapper not found in the prompt is skipped rather than risk corrupting it.
|
|
390
|
+
* Returns the rewritten prompt and the files that survived exclusion (stripped). */
|
|
391
|
+
function rewriteNativeBlocks(prompt: string, native: Array<{ path: string; content: string }>, excluded: (absPath: string) => boolean): { prompt: string; changed: boolean; kept: Array<{ path: string; content: string }> } {
|
|
392
|
+
let changed = false
|
|
393
|
+
const kept: Array<{ path: string; content: string }> = []
|
|
394
|
+
for (const file of native) {
|
|
395
|
+
const wrapper = instructionsBlock(file.path, file.content)
|
|
396
|
+
if (excluded(file.path)) {
|
|
397
|
+
const removed = removeBlock(prompt, wrapper)
|
|
398
|
+
if (removed !== null) {
|
|
399
|
+
prompt = removed
|
|
400
|
+
changed = true
|
|
401
|
+
}
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const stripped = stripBlockComments(file.content)
|
|
405
|
+
if (stripped !== file.content) {
|
|
406
|
+
const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
|
|
407
|
+
if (replaced !== null) {
|
|
408
|
+
prompt = replaced
|
|
409
|
+
changed = true
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
kept.push({ path: file.path, content: stripped })
|
|
413
|
+
}
|
|
414
|
+
return { prompt, changed, kept }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Claude's --add-dir memory files, minus any pi already loaded natively (added to
|
|
418
|
+
* `seenSet` here so an @import cannot pull one in twice) and any the excludes drop
|
|
419
|
+
* or that strip to nothing. Each survivor is comment-stripped and tagged with its
|
|
420
|
+
* additional dir, so its relative imports can resolve from there. */
|
|
421
|
+
function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded: (absPath: string) => boolean, includeLocal: boolean): Array<{ path: string; content: string; dir: string }> {
|
|
422
|
+
const extras: Array<{ path: string; content: string; dir: string }> = []
|
|
423
|
+
for (const dir of addDirs) {
|
|
424
|
+
for (const file of additionalDirContextFiles(dir, includeLocal)) {
|
|
425
|
+
const [real] = realRoots([file.path])
|
|
426
|
+
const key = real ?? file.path
|
|
427
|
+
if (seenSet.has(key)) continue // pi already loaded it natively
|
|
428
|
+
seenSet.add(key)
|
|
429
|
+
if (excluded(file.path)) continue
|
|
430
|
+
const stripped = stripBlockComments(file.content)
|
|
431
|
+
if (stripped.trim().length === 0) continue
|
|
432
|
+
extras.push({ path: file.path, content: stripped, dir })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return extras
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Resolve every context and additional-dir file's @imports through the one shared
|
|
439
|
+
* budget, each with roots scoped to the importing file so a project file never
|
|
440
|
+
* reaches user config. */
|
|
441
|
+
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, home: string, cwd: string, seenSet: Set<string>, excluded: (absPath: string) => boolean, budget: ImportBudget): ImportedFile[] {
|
|
442
|
+
const imported: ImportedFile[] = []
|
|
443
|
+
for (const file of contextFiles) {
|
|
444
|
+
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
445
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
|
|
446
|
+
}
|
|
447
|
+
for (const extra of extras) {
|
|
448
|
+
// The additional dir itself is an allowed root, so its files' relative imports
|
|
449
|
+
// resolve even from .claude/rules two levels down.
|
|
450
|
+
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
451
|
+
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
|
|
452
|
+
}
|
|
453
|
+
return imported
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The CLAUDE.local.md bodies appended after the native context, announced as they
|
|
457
|
+
* are added (only the non-empty ones, matching what actually reaches the prompt). */
|
|
458
|
+
function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
459
|
+
let addition = ''
|
|
460
|
+
for (const local of keptLocals) {
|
|
461
|
+
if (local.content.trim().length > 0) {
|
|
462
|
+
addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
|
|
463
|
+
announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return addition
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The --add-dir memory files appended as extra project_instructions blocks.
|
|
470
|
+
* Additional dirs are extra working directories, so their files are Project-typed
|
|
471
|
+
* regardless of where the dir sits (Local for a CLAUDE.local.md). */
|
|
472
|
+
function additionalDirsAddition(extras: Array<{ path: string; content: string; dir: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
473
|
+
let addition = ''
|
|
474
|
+
for (const extra of extras) {
|
|
475
|
+
addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
|
|
476
|
+
announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
|
|
477
|
+
}
|
|
478
|
+
return addition
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
482
|
+
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
483
|
+
* was imported. */
|
|
484
|
+
function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
485
|
+
if (imported.length === 0) return ''
|
|
486
|
+
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
487
|
+
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
488
|
+
for (const entry of imported) {
|
|
489
|
+
announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
|
|
490
|
+
}
|
|
491
|
+
return `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
492
|
+
}
|
|
493
|
+
|
|
360
494
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
361
495
|
let localContexts: Array<{ path: string; content: string }> = []
|
|
362
496
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
@@ -416,37 +550,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
416
550
|
const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
|
|
417
551
|
const projectRoot = repoRoot(cwd) ?? cwd
|
|
418
552
|
|
|
419
|
-
let prompt = event.systemPrompt
|
|
420
|
-
let changed = false
|
|
421
|
-
|
|
422
553
|
// claudeMdExcludes drops an excluded file's block from the assembled prompt and
|
|
423
554
|
// from import expansion; surviving blocks get block-level comments stripped.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
prompt = removed
|
|
433
|
-
changed = true
|
|
434
|
-
}
|
|
435
|
-
continue
|
|
436
|
-
}
|
|
437
|
-
const stripped = stripBlockComments(file.content)
|
|
438
|
-
if (stripped !== file.content) {
|
|
439
|
-
const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
|
|
440
|
-
if (replaced !== null) {
|
|
441
|
-
prompt = replaced
|
|
442
|
-
changed = true
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
keptNative.push({ path: file.path, content: stripped })
|
|
446
|
-
// Exclusion is owned here, so the session_start InstructionsLoaded events
|
|
447
|
-
// for pi's native context files are published here too, only for files
|
|
448
|
-
// that actually survived it: Claude fires no event for a file it never
|
|
449
|
-
// loaded. The hooks extension consumes them off the shared bus.
|
|
555
|
+
const rewrite = rewriteNativeBlocks(event.systemPrompt, native, excluded)
|
|
556
|
+
let prompt = rewrite.prompt
|
|
557
|
+
let changed = rewrite.changed
|
|
558
|
+
// Exclusion is owned here, so the session_start InstructionsLoaded events for
|
|
559
|
+
// pi's native context files are published here too, only for files that
|
|
560
|
+
// survived it: Claude fires no event for a file it never loaded. The hooks
|
|
561
|
+
// extension consumes them off the shared bus.
|
|
562
|
+
for (const file of rewrite.kept) {
|
|
450
563
|
announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
|
|
451
564
|
}
|
|
452
565
|
|
|
@@ -460,69 +573,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
460
573
|
}
|
|
461
574
|
|
|
462
575
|
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
463
|
-
const contextFiles = [...
|
|
576
|
+
const contextFiles = [...rewrite.kept, ...keptLocals]
|
|
464
577
|
|
|
465
578
|
// Seed with every loaded context file path, excluded ones included, so pi's own
|
|
466
579
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
467
|
-
const
|
|
468
|
-
const seenSet = new Set(seen)
|
|
580
|
+
const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
|
|
469
581
|
|
|
470
582
|
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
471
583
|
// before import expansion so an @import cannot pull one in twice, and they get
|
|
472
584
|
// the same exclude and comment-strip treatment as native context files.
|
|
473
585
|
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
474
|
-
const extras
|
|
475
|
-
for (const dir of addDirs) {
|
|
476
|
-
for (const file of additionalDirContextFiles(dir, projectApproved)) {
|
|
477
|
-
const [real] = realRoots([file.path])
|
|
478
|
-
const key = real ?? file.path
|
|
479
|
-
if (seenSet.has(key)) continue // pi already loaded it natively
|
|
480
|
-
seenSet.add(key)
|
|
481
|
-
if (excluded(file.path)) continue
|
|
482
|
-
const stripped = stripBlockComments(file.content)
|
|
483
|
-
if (stripped.trim().length === 0) continue
|
|
484
|
-
extras.push({ path: file.path, content: stripped, dir })
|
|
485
|
-
}
|
|
486
|
-
}
|
|
586
|
+
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
487
587
|
|
|
488
|
-
const imported: ImportedFile[] = []
|
|
489
588
|
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
490
589
|
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
491
590
|
// it is read, so its transitive imports never load and it spends no budget.
|
|
492
591
|
const budget = createImportBudget()
|
|
493
|
-
|
|
494
|
-
// Roots are scoped per importing file: a project file never reaches user config.
|
|
495
|
-
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
496
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget, 0, file.path, excluded))
|
|
497
|
-
}
|
|
498
|
-
for (const extra of extras) {
|
|
499
|
-
// The additional dir itself is an allowed root, so its files' relative
|
|
500
|
-
// imports resolve even from .claude/rules two levels down.
|
|
501
|
-
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
502
|
-
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, budget, 0, extra.path, excluded))
|
|
503
|
-
}
|
|
592
|
+
const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
|
|
504
593
|
|
|
505
|
-
let addition =
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
|
|
509
|
-
announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
for (const extra of extras) {
|
|
513
|
-
addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
|
|
514
|
-
// Additional dirs are extra working directories, so their memory files are
|
|
515
|
-
// Project-typed regardless of where the dir sits (Local for CLAUDE.local.md).
|
|
516
|
-
announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
|
|
517
|
-
}
|
|
518
|
-
if (imported.length > 0) {
|
|
519
|
-
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
520
|
-
const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
|
|
521
|
-
addition += `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
522
|
-
for (const entry of imported) {
|
|
523
|
-
announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
|
|
524
|
-
}
|
|
525
|
-
}
|
|
594
|
+
let addition = localContextAddition(keptLocals, announce)
|
|
595
|
+
addition += additionalDirsAddition(extras, announce)
|
|
596
|
+
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
526
597
|
if (!changed && addition.length === 0) return
|
|
527
598
|
|
|
528
599
|
return { systemPrompt: prompt + addition }
|
package/extensions/hooks.ts
CHANGED
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
*
|
|
44
44
|
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
45
45
|
* .claude/settings.json and settings.local.json (only when the project is
|
|
46
|
-
* trusted, since hooks execute arbitrary shell).
|
|
46
|
+
* trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
|
|
47
|
+
* setting (managed settings or any honored file in that chain) short-circuits
|
|
48
|
+
* the load entirely, so no event fires any hook; /hooks prints the resolved
|
|
49
|
+
* chain per event with each entry's source settings file. Matchers follow Claude's rule:
|
|
47
50
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
48
51
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
49
52
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -60,6 +63,7 @@ import type { Api, Model } from '@earendil-works/pi-ai'
|
|
|
60
63
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
61
64
|
import { runAgent } from './internal/agent-run.js'
|
|
62
65
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
66
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
63
67
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
64
68
|
import { callMcpTool } from './internal/mcp-call.js'
|
|
65
69
|
import { completeText } from './internal/model-complete.js'
|
|
@@ -94,7 +98,7 @@ interface HookCommand {
|
|
|
94
98
|
model?: string
|
|
95
99
|
systemPrompt?: string
|
|
96
100
|
}
|
|
97
|
-
interface HookMatcher {
|
|
101
|
+
export interface HookMatcher {
|
|
98
102
|
matcher?: string
|
|
99
103
|
hooks: HookCommand[]
|
|
100
104
|
}
|
|
@@ -133,7 +137,26 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
|
|
|
133
137
|
return files
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
/** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
|
|
141
|
+
* hook misbehaves, so it is honored before any hook runs. Disabled when managed
|
|
142
|
+
* settings or ANY file in the settings chain sets it to `true`; deliberately not
|
|
143
|
+
* last-file-wins, since a repository file re-enabling the hooks the user just
|
|
144
|
+
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
145
|
+
* already gates project files on trust (see hookFiles). */
|
|
146
|
+
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
147
|
+
if (managed.disableAllHooks === true) return true
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
151
|
+
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
152
|
+
} catch {
|
|
153
|
+
// missing or invalid file: skip
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
137
160
|
const config: HooksConfig = {}
|
|
138
161
|
for (const file of files) {
|
|
139
162
|
let raw: string
|
|
@@ -142,12 +165,12 @@ export function loadHooks(files: string[]): HooksConfig {
|
|
|
142
165
|
} catch {
|
|
143
166
|
continue
|
|
144
167
|
}
|
|
145
|
-
mergeHooksJson(config, raw, file)
|
|
168
|
+
mergeHooksJson(config, raw, file, sources)
|
|
146
169
|
}
|
|
147
170
|
return config
|
|
148
171
|
}
|
|
149
172
|
|
|
150
|
-
function mergeHooksJson(config: HooksConfig, raw: string, source: string): void {
|
|
173
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
|
|
151
174
|
let parsed: { hooks?: HooksConfig }
|
|
152
175
|
try {
|
|
153
176
|
parsed = JSON.parse(raw)
|
|
@@ -161,26 +184,30 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string): void
|
|
|
161
184
|
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
162
185
|
// call for the rest of the session failed with an opaque type error.
|
|
163
186
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
164
|
-
if (usable.length
|
|
187
|
+
if (usable.length === 0) continue
|
|
188
|
+
config[event] = [...(config[event] ?? []), ...usable]
|
|
189
|
+
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
190
|
+
// viewer's source attribution without touching the entries themselves.
|
|
191
|
+
for (const entry of usable) sources?.set(entry, source)
|
|
165
192
|
}
|
|
166
193
|
}
|
|
167
194
|
|
|
168
195
|
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
169
196
|
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
170
197
|
* hook can name its bundled scripts by real path. */
|
|
171
|
-
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[]): void {
|
|
198
|
+
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
172
199
|
for (const plugin of plugins) {
|
|
173
200
|
const declared = plugin.manifest.hooks
|
|
174
201
|
// An inline hooks object; an array is not a valid hooks map (it would parse to
|
|
175
202
|
// numeric event keys), so it falls through to the default path rather than
|
|
176
203
|
// silently registering nothing.
|
|
177
204
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
178
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)
|
|
205
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
179
206
|
continue
|
|
180
207
|
}
|
|
181
208
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
182
209
|
try {
|
|
183
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file)
|
|
210
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
184
211
|
} catch {
|
|
185
212
|
// a plugin without hooks contributes nothing
|
|
186
213
|
}
|
|
@@ -249,6 +276,23 @@ function isRunnableHook(hook: HookCommand): boolean {
|
|
|
249
276
|
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
250
277
|
}
|
|
251
278
|
|
|
279
|
+
/** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
|
|
280
|
+
* entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
|
|
281
|
+
* (undefined or `command` type) already has one, so this is undefined. */
|
|
282
|
+
function syntheticCommand(hook: HookCommand): string | undefined {
|
|
283
|
+
if (hook.type === 'http') return hook.url
|
|
284
|
+
if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
|
|
285
|
+
if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
|
|
286
|
+
return undefined
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A matched entry with its `command` filled in: mirroring the synthetic identity into
|
|
290
|
+
* `command` keeps dedup, timeout messages and display working for non-shell hooks. */
|
|
291
|
+
function withCommand(raw: HookCommand): HookCommand {
|
|
292
|
+
const identity = syntheticCommand(raw)
|
|
293
|
+
return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
294
|
+
}
|
|
295
|
+
|
|
252
296
|
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
253
297
|
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
254
298
|
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
@@ -258,11 +302,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
258
302
|
for (const entry of matchers ?? []) {
|
|
259
303
|
if (!matcherApplies(entry.matcher, candidates)) continue
|
|
260
304
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
261
|
-
|
|
262
|
-
// url / prompt / server:tool. Mirroring it into `command` keeps dedup, timeout
|
|
263
|
-
// messages and display working.
|
|
264
|
-
const identity = raw.type === 'http' ? raw.url : raw.type === 'prompt' || raw.type === 'agent' ? raw.prompt : raw.type === 'mcp_tool' ? `${raw.server}:${raw.tool}` : undefined
|
|
265
|
-
const hook = identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
305
|
+
const hook = withCommand(raw)
|
|
266
306
|
// Claude runs a handler defined in more than one settings file once.
|
|
267
307
|
if (seen.has(hook.command)) continue
|
|
268
308
|
seen.add(hook.command)
|
|
@@ -272,6 +312,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
272
312
|
return result
|
|
273
313
|
}
|
|
274
314
|
|
|
315
|
+
/** A hook entry's display identity for the /hooks viewer: the command for shell
|
|
316
|
+
* hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
|
|
317
|
+
* is named rather than hidden, since a misconfigured entry is exactly what the
|
|
318
|
+
* viewer exists to surface. */
|
|
319
|
+
function hookIdentity(hook: HookCommand | null | undefined): string {
|
|
320
|
+
// A hand-edited settings file can leave a null (or otherwise empty) entry in a
|
|
321
|
+
// hooks array; name it rather than let it crash the viewer that exists to surface
|
|
322
|
+
// exactly this kind of misconfiguration.
|
|
323
|
+
const record: Partial<HookCommand> = hook ?? {}
|
|
324
|
+
const type = record.type ?? 'command'
|
|
325
|
+
if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
|
|
326
|
+
if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
|
|
327
|
+
if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
|
|
328
|
+
return `command: ${record.command ?? '(missing command)'}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Render the resolved hooks config as a readable per-event summary for /hooks:
|
|
332
|
+
* one line per configured hook with its matcher, identity and, when known, the
|
|
333
|
+
* settings file it came from. Pure formatting of already-resolved data. */
|
|
334
|
+
export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
|
|
335
|
+
const lines: string[] = []
|
|
336
|
+
for (const [event, matchers] of Object.entries(config)) {
|
|
337
|
+
const entryLines: string[] = []
|
|
338
|
+
for (const entry of matchers) {
|
|
339
|
+
const matcher = entry.matcher || '*'
|
|
340
|
+
const source = sources?.get(entry)
|
|
341
|
+
const suffix = source ? ` (${source})` : ''
|
|
342
|
+
for (const hook of entry.hooks ?? []) {
|
|
343
|
+
entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
|
|
347
|
+
}
|
|
348
|
+
if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
|
|
349
|
+
return lines.join('\n')
|
|
350
|
+
}
|
|
351
|
+
|
|
275
352
|
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
276
353
|
try {
|
|
277
354
|
return JSON.parse(text)
|
|
@@ -369,7 +446,7 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
|
|
|
369
446
|
const allowedSet = new Set(allowed ?? [])
|
|
370
447
|
const out: Record<string, string> = {}
|
|
371
448
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
372
|
-
out[key] = value.replace(/\$(?:\{([A-Za-z_]
|
|
449
|
+
out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
|
|
373
450
|
const name = braced ?? bare ?? ''
|
|
374
451
|
return allowedSet.has(name) ? (process.env[name] ?? '') : ''
|
|
375
452
|
})
|
|
@@ -646,12 +723,32 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
646
723
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
647
724
|
}
|
|
648
725
|
|
|
726
|
+
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
727
|
+
* tool result: a block notice (exit-2 stderr, or decision:block on success) followed
|
|
728
|
+
* by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
|
|
729
|
+
* but never a decision:block verdict. */
|
|
730
|
+
function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
|
|
731
|
+
const lines: string[] = []
|
|
732
|
+
const parsed = tryParseJson(result.stdout)
|
|
733
|
+
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
734
|
+
// success, exit-2 / decision:block feed back as a block notice.
|
|
735
|
+
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
736
|
+
else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
737
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
738
|
+
if (context) lines.push(context)
|
|
739
|
+
return lines
|
|
740
|
+
}
|
|
741
|
+
|
|
649
742
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
650
743
|
let config: HooksConfig = {}
|
|
651
744
|
let projectDir = ''
|
|
652
745
|
let pendingSessionContext: string[] = []
|
|
653
746
|
let stopHookActive = false
|
|
654
747
|
let sessionCtx: ExtensionContext | undefined
|
|
748
|
+
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
749
|
+
let hooksDisabled = false
|
|
750
|
+
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
751
|
+
const hookSources = new Map<HookMatcher, string>()
|
|
655
752
|
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
656
753
|
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
657
754
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
@@ -728,10 +825,20 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
728
825
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
729
826
|
// subdirectory session too.
|
|
730
827
|
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
731
|
-
|
|
828
|
+
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
829
|
+
hookSources.clear()
|
|
830
|
+
// The disableAllHooks escape hatch, checked before any config loads: with no
|
|
831
|
+
// config resolved, no event, plugin hooks included, can fire a hook.
|
|
832
|
+
hooksDisabled = readDisableAllHooks(files)
|
|
833
|
+
if (hooksDisabled) {
|
|
834
|
+
config = {}
|
|
835
|
+
pendingSessionContext = []
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
config = loadHooks(files, hookSources)
|
|
732
839
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
733
840
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
734
|
-
loadPluginHooks(config, installedPlugins(os.homedir()))
|
|
841
|
+
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
735
842
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
736
843
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
737
844
|
if (event.reason === 'reload') return
|
|
@@ -784,16 +891,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
784
891
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
785
892
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
786
893
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
787
|
-
const feedback
|
|
788
|
-
for (const result of results) {
|
|
789
|
-
const parsed = tryParseJson(result.stdout)
|
|
790
|
-
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
791
|
-
// success, exit-2 / decision:block feed back as a block notice.
|
|
792
|
-
if (!result.timedOut && result.code === 2) feedback.push(`${eventName} hook: ${result.stderr.trim() || (event.isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
793
|
-
else if (!event.isError && parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
794
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
795
|
-
if (context) feedback.push(context)
|
|
796
|
-
}
|
|
894
|
+
const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
|
|
797
895
|
if (feedback.length === 0) return
|
|
798
896
|
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
799
897
|
})
|
|
@@ -869,4 +967,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
869
967
|
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
870
968
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
871
969
|
})
|
|
970
|
+
|
|
971
|
+
// Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
|
|
972
|
+
// are otherwise opaque, so showing the resolved chain per event, with the settings
|
|
973
|
+
// file each entry came from, is the debugging surface.
|
|
974
|
+
pi.registerCommand('hooks', {
|
|
975
|
+
description: 'Show the hook configuration resolved from settings',
|
|
976
|
+
handler: async (_args, ctx) => {
|
|
977
|
+
if (hooksDisabled) {
|
|
978
|
+
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
|
|
982
|
+
},
|
|
983
|
+
})
|
|
872
984
|
}
|