pi-code 1.0.56 → 1.0.58
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 +3 -4
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +250 -22
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +4 -3
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/project-approval.ts +14 -0
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/tool-target.ts +23 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +12 -2
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- package/package.json +1 -1
|
@@ -29,6 +29,7 @@ import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './interna
|
|
|
29
29
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
30
30
|
import { findNearestDir } from './internal/project-root.js'
|
|
31
31
|
import { stripBlockComments } from './internal/strip-comments.js'
|
|
32
|
+
import { fileToolTarget } from './internal/tool-target.js'
|
|
32
33
|
|
|
33
34
|
export interface Frontmatter {
|
|
34
35
|
paths: string[]
|
|
@@ -341,10 +342,8 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
|
341
342
|
// read or edited rather than inlining it upfront.
|
|
342
343
|
pi.on('tool_result', async (event, ctx) => {
|
|
343
344
|
if (attachTargets.length === 0) return
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
const rel = (event.input as { path?: unknown } | undefined)?.path
|
|
347
|
-
if (typeof rel !== 'string' || rel.length === 0) return
|
|
345
|
+
const rel = fileToolTarget(event)
|
|
346
|
+
if (rel === undefined) return
|
|
348
347
|
// Realpath both sides (roots canonicalise at session_start): a tool reporting
|
|
349
348
|
// the resolved real path in a symlinked checkout must still match.
|
|
350
349
|
const abs = realpathOr(path.resolve(ctx.cwd, rel))
|
package/extensions/commands.ts
CHANGED
|
@@ -44,7 +44,8 @@ import * as path from 'node:path'
|
|
|
44
44
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
45
45
|
import { Type } from 'typebox'
|
|
46
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
47
|
-
import { type
|
|
47
|
+
import { type DiscoveredCommand, discoverCommandFiles, type ParsedCommand, type PathRuleTool, parseCommandFile, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
|
+
import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanExec } from './internal/command-spans.js'
|
|
48
49
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
49
50
|
import { claudeEffortLevel } from './internal/effort.js'
|
|
50
51
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
@@ -55,6 +56,7 @@ import { isProjectApproved } from './internal/project-approval.js'
|
|
|
55
56
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
56
57
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
57
58
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
59
|
+
import { errorMessage, isDirectory } from './internal/values.js'
|
|
58
60
|
|
|
59
61
|
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
60
62
|
interface ModelLike {
|
|
@@ -105,14 +107,6 @@ interface VarContext {
|
|
|
105
107
|
modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
function isDirectory(target: string): boolean {
|
|
109
|
-
try {
|
|
110
|
-
return fs.statSync(target).isDirectory()
|
|
111
|
-
} catch {
|
|
112
|
-
return false
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
110
|
/** Existing `.claude/commands` directories in Claude's precedence order (later
|
|
117
111
|
* directories win in collectCommands): project first, then personal, then the
|
|
118
112
|
* enterprise directory beside the managed settings file, per "enterprise
|
|
@@ -328,7 +322,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
328
322
|
// A refused switch leaves the turn on the session model rather than the one the
|
|
329
323
|
// command named, and the reply gives no sign of it, so the refusal is reported.
|
|
330
324
|
void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
|
|
331
|
-
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${
|
|
325
|
+
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
|
|
332
326
|
})
|
|
333
327
|
},
|
|
334
328
|
})
|
|
@@ -455,7 +449,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
455
449
|
} catch (error) {
|
|
456
450
|
// A failed injected command aborts the invocation; the model never sees a
|
|
457
451
|
// half-expanded body. The notify carries Claude's failure message format.
|
|
458
|
-
ctx.ui.notify(
|
|
452
|
+
ctx.ui.notify(errorMessage(error), 'error')
|
|
459
453
|
return
|
|
460
454
|
}
|
|
461
455
|
|
|
@@ -513,7 +507,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
513
507
|
// An unreadable file must not take down session start, but the command is then
|
|
514
508
|
// absent from /help and unresolvable by the model, which looks like one that was
|
|
515
509
|
// never written.
|
|
516
|
-
console.warn(`pi-code-commands: ignoring ${command.filePath}: ${
|
|
510
|
+
console.warn(`pi-code-commands: ignoring ${command.filePath}: ${errorMessage(error)}`)
|
|
517
511
|
continue
|
|
518
512
|
}
|
|
519
513
|
discovered.set(command.name, command)
|
|
@@ -72,13 +72,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
72
72
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
73
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
74
74
|
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
75
|
-
import { sliceBytes } from './internal/output-guard.js'
|
|
75
|
+
import { capForContext, sliceBytes } from './internal/output-guard.js'
|
|
76
76
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
77
|
-
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
77
|
+
import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
78
78
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
79
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
79
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
80
80
|
import { statToken } from './internal/stat-token.js'
|
|
81
81
|
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
82
|
+
import { fileToolTarget } from './internal/tool-target.js'
|
|
82
83
|
|
|
83
84
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
84
85
|
const MAX_IMPORT_DEPTH = 4
|
|
@@ -138,9 +139,12 @@ export interface ImportBudget {
|
|
|
138
139
|
files: number
|
|
139
140
|
bytes: number
|
|
140
141
|
dropped: number
|
|
142
|
+
/** Existing files an @import named that resolve outside the importer's allowed
|
|
143
|
+
* roots. Collected so the refusal can be reported rather than left silent. */
|
|
144
|
+
refused: Set<string>
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
|
|
147
|
+
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
|
|
144
148
|
|
|
145
149
|
/** The `@path` targets of a context file, in document order. Claude Code evaluates
|
|
146
150
|
* imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
|
|
@@ -162,16 +166,28 @@ function importTargets(content: string): string[] {
|
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
/** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
|
|
165
|
-
function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded
|
|
169
|
+
function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refused: Set<string>): { real: string; body: string } | null {
|
|
166
170
|
const resolved = path.resolve(fromDir, expandHome(target, home))
|
|
171
|
+
// Always the path the importing file named, never where a symlink pointed: the
|
|
172
|
+
// notice would otherwise hand a repo the real name of whatever the link reaches,
|
|
173
|
+
// which is the disclosure the refusal exists to prevent. An excluded file is not
|
|
174
|
+
// named either, since exclusion removes it from every other surface too.
|
|
175
|
+
const refuse = (): null => {
|
|
176
|
+
if (isExcluded?.(resolved) !== true) refused.add(resolved)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
167
179
|
let real: string
|
|
168
180
|
try {
|
|
169
181
|
real = fs.realpathSync(resolved)
|
|
170
182
|
} catch {
|
|
171
|
-
|
|
183
|
+
// Not on disk. Still refused when it points outside, so that whether a path is
|
|
184
|
+
// reported never depends on whether it exists: a notice that named only the
|
|
185
|
+
// existing ones would enumerate the filesystem for any repo-controlled file
|
|
186
|
+
// willing to write one @line per guess.
|
|
187
|
+
return isUnder(resolved, allowedRoots) ? null : refuse()
|
|
172
188
|
}
|
|
173
189
|
if (seen.has(real)) return null
|
|
174
|
-
if (!isUnder(real, allowedRoots)) return
|
|
190
|
+
if (!isUnder(real, allowedRoots)) return refuse()
|
|
175
191
|
// Checked before the read so an excluded file contributes nothing: no body, no
|
|
176
192
|
// transitive imports, no budget spend, no announce. A post-collection filter
|
|
177
193
|
// would drop the file itself but keep its children.
|
|
@@ -182,6 +198,9 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
182
198
|
// Only a consumed file dedupes: marking a blocked or unreadable target seen
|
|
183
199
|
// would let one reader's failure suppress the import for a later, allowed one.
|
|
184
200
|
seen.add(real)
|
|
201
|
+
// A file another importer already refused is in context after all; the notice
|
|
202
|
+
// must not claim otherwise.
|
|
203
|
+
refused.delete(resolved)
|
|
185
204
|
return { real, body }
|
|
186
205
|
} catch {
|
|
187
206
|
return null
|
|
@@ -218,7 +237,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
|
|
|
218
237
|
scan.budget.dropped += 1
|
|
219
238
|
continue
|
|
220
239
|
}
|
|
221
|
-
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
240
|
+
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
|
|
222
241
|
if (!file) continue
|
|
223
242
|
scan.budget.files -= 1
|
|
224
243
|
// The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
|
|
@@ -406,15 +425,7 @@ export function readClaudeMdExcludes(files: string[], managed: Record<string, un
|
|
|
406
425
|
if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
|
|
407
426
|
}
|
|
408
427
|
}
|
|
409
|
-
for (const
|
|
410
|
-
try {
|
|
411
|
-
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
412
|
-
if (settings === null || typeof settings !== 'object') continue
|
|
413
|
-
collect((settings as Record<string, unknown>).claudeMdExcludes)
|
|
414
|
-
} catch {
|
|
415
|
-
// missing or invalid settings file: skip
|
|
416
|
-
}
|
|
417
|
-
}
|
|
428
|
+
for (const settings of readSettingsChain(files)) collect(settings.claudeMdExcludes)
|
|
418
429
|
collect(managed.claudeMdExcludes)
|
|
419
430
|
return globs
|
|
420
431
|
}
|
|
@@ -505,6 +516,156 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
|
|
|
505
516
|
return imported
|
|
506
517
|
}
|
|
507
518
|
|
|
519
|
+
/** Every context file that could load on demand for a touched directory, shallowest
|
|
520
|
+
* first, so the deepest instructions are read last as they are at launch. */
|
|
521
|
+
function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
|
|
522
|
+
for (const dir of nestedContextDirs(touchedDir, realCwd)) {
|
|
523
|
+
for (const name of NESTED_CONTEXT_NAMES) yield { file: path.join(dir, name), dir, name }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Everything the on-demand load needs from the session and the tool call, grouped so
|
|
528
|
+
* the per-file worker stays a three-argument function. */
|
|
529
|
+
interface NestedLoadContext {
|
|
530
|
+
home: string
|
|
531
|
+
cwd: string
|
|
532
|
+
realCwd: string
|
|
533
|
+
projectRoot: string
|
|
534
|
+
excludeGlobs: string[]
|
|
535
|
+
/** The file whose access triggered the load, for trigger_file_path. */
|
|
536
|
+
touched: string
|
|
537
|
+
/** Paths already in the system prompt, so a nested @import does not repeat one. */
|
|
538
|
+
launchLoaded: string[]
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** One nested context file's block and the instruction loads it should announce, or
|
|
542
|
+
* nothing to attach: absent, excluded, empty, or reaching outside the project. `read`
|
|
543
|
+
* reports whether the file was there at all, so a file that exists is only ever
|
|
544
|
+
* attached once while a missing one can still appear later in the session. */
|
|
545
|
+
function nestedContextBlock(file: string, dir: string, load: NestedLoadContext): { read: boolean; text?: string; events?: InstructionLoadEvent[] } {
|
|
546
|
+
if (isExcludedPath(file, load.excludeGlobs, load.home)) return { read: false }
|
|
547
|
+
// The file itself may be a link out of the project, whatever its directory is.
|
|
548
|
+
const [real] = realRoots([file])
|
|
549
|
+
if (real === undefined || !isUnder(real, [load.realCwd])) return { read: false }
|
|
550
|
+
const content = readContextFile(real)
|
|
551
|
+
if (content === undefined) return { read: false }
|
|
552
|
+
const body = stripBlockComments(content).trim()
|
|
553
|
+
if (body.length === 0) return { read: true }
|
|
554
|
+
// Its own @imports resolve at project roots, on a budget of their own: this load is
|
|
555
|
+
// outside the launch-time expansion the shared budget covers. The launch-time paths
|
|
556
|
+
// seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
|
|
557
|
+
// a body already in the system prompt.
|
|
558
|
+
const seen = new Set([...load.launchLoaded, real])
|
|
559
|
+
const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
|
|
560
|
+
importer: real,
|
|
561
|
+
isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
|
|
562
|
+
budget: createImportBudget(),
|
|
563
|
+
})
|
|
564
|
+
return {
|
|
565
|
+
read: true,
|
|
566
|
+
text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n'),
|
|
567
|
+
events: [
|
|
568
|
+
{ file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
|
|
569
|
+
...imports.map((entry) => ({
|
|
570
|
+
file_path: entry.path,
|
|
571
|
+
memory_type: memoryTypeForPath(entry.path, load.home, load.projectRoot),
|
|
572
|
+
load_reason: 'include',
|
|
573
|
+
parent_file_path: file,
|
|
574
|
+
})),
|
|
575
|
+
],
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The context files Claude loads on demand rather than at launch, in the order it
|
|
580
|
+
* reads them within a directory. */
|
|
581
|
+
const NESTED_CONTEXT_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
|
|
582
|
+
|
|
583
|
+
/** The directories between a touched file and cwd, nearest cwd first.
|
|
584
|
+
*
|
|
585
|
+
* Claude loads CLAUDE.md from cwd and every directory above it at launch, and the
|
|
586
|
+
* ones below "are included when Claude reads files in those directories"
|
|
587
|
+
* (memory.md). Only the strictly-below range belongs here; cwd's own file is
|
|
588
|
+
* already in the prompt. A file outside cwd contributes nothing.
|
|
589
|
+
*/
|
|
590
|
+
function nestedContextDirs(from: string, cwd: string): string[] {
|
|
591
|
+
const dirs: string[] = []
|
|
592
|
+
let current = from
|
|
593
|
+
while (current !== cwd && current !== path.dirname(current)) {
|
|
594
|
+
dirs.unshift(current)
|
|
595
|
+
current = path.dirname(current)
|
|
596
|
+
}
|
|
597
|
+
// The walk reached the filesystem root without meeting cwd, so the file is outside
|
|
598
|
+
// it: nothing below cwd to load. A file in cwd itself ends the loop with no dirs.
|
|
599
|
+
return current === cwd ? dirs : []
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The context-file names pi prefers over CLAUDE.md in the same directory. Mirrors
|
|
603
|
+
* pi's own lookup order (init.ts CONTEXT_FILE_CANDIDATES); the sibling search below
|
|
604
|
+
* keys off what pi actually loaded, so this is only used to recognize those files. */
|
|
605
|
+
const AGENTS_FILE_NAMES = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
|
|
606
|
+
|
|
607
|
+
/** The blocks and instruction loads for one touched directory. `loaded` is the
|
|
608
|
+
* session's set of already-attached files and is updated in place, so a file that
|
|
609
|
+
* exists is attached once and a missing one can still appear later. */
|
|
610
|
+
function nestedContextAttachments(touchedDir: string, load: NestedLoadContext, loaded: Set<string>, localsApproved: boolean): { bodies: string[]; events: InstructionLoadEvent[] } {
|
|
611
|
+
const bodies: string[] = []
|
|
612
|
+
const events: InstructionLoadEvent[] = []
|
|
613
|
+
for (const { file, dir, name } of nestedCandidates(touchedDir, load.realCwd)) {
|
|
614
|
+
if (loaded.has(file)) continue
|
|
615
|
+
// A CLAUDE.local.md needs a decision, never the "nothing here to gate" shortcut:
|
|
616
|
+
// the approval walk only looks at or above cwd, so this is the one door it cannot
|
|
617
|
+
// see (see isGatedFileApproved).
|
|
618
|
+
if (name === 'CLAUDE.local.md' && !localsApproved) continue
|
|
619
|
+
const block = nestedContextBlock(file, dir, load)
|
|
620
|
+
if (block.read) loaded.add(file)
|
|
621
|
+
if (block.text === undefined) continue
|
|
622
|
+
bodies.push(block.text)
|
|
623
|
+
events.push(...(block.events ?? []))
|
|
624
|
+
}
|
|
625
|
+
return { bodies, events }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The CLAUDE.md files pi passed over.
|
|
629
|
+
*
|
|
630
|
+
* Claude Code reads CLAUDE.md and never AGENTS.md, and its documented recipe for a
|
|
631
|
+
* repository that already has an AGENTS.md is a CLAUDE.md that imports it and adds
|
|
632
|
+
* Claude-specific instructions below (memory.md, "AGENTS.md"). pi loads one context
|
|
633
|
+
* file per directory and prefers AGENTS.md, so on exactly that layout the
|
|
634
|
+
* Claude-specific half never reaches the prompt. Each one found here is loaded like
|
|
635
|
+
* any other project file: exclude-checked, comment-stripped, imports expanded.
|
|
636
|
+
*
|
|
637
|
+
* The `@AGENTS.md` the recipe opens with costs nothing: pi's own file paths already
|
|
638
|
+
* seed the import seen-set, so the body it names is not injected a second time. */
|
|
639
|
+
function siblingClaudeMdFiles(native: Array<{ path: string; content: string }>): Array<{ path: string; content: string }> {
|
|
640
|
+
const loaded = new Set(realRoots(native.map((file) => file.path)))
|
|
641
|
+
const found: Array<{ path: string; content: string }> = []
|
|
642
|
+
for (const file of native) {
|
|
643
|
+
if (!AGENTS_FILE_NAMES.has(path.basename(file.path))) continue
|
|
644
|
+
// CLAUDE.md only: pi also answers to CLAUDE.MD, but Claude Code reads the one
|
|
645
|
+
// spelling, and on a case-insensitive filesystem looking for both finds the same
|
|
646
|
+
// file twice under two names.
|
|
647
|
+
const candidate = path.join(path.dirname(file.path), 'CLAUDE.md')
|
|
648
|
+
const [real] = realRoots([candidate])
|
|
649
|
+
if (real !== undefined && loaded.has(real)) continue
|
|
650
|
+
const content = readContextFile(candidate)
|
|
651
|
+
if (content === undefined) continue
|
|
652
|
+
found.push({ path: candidate, content })
|
|
653
|
+
}
|
|
654
|
+
return found
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The CLAUDE.md files pi passed over, appended as project_instructions blocks in the
|
|
658
|
+
* ./CLAUDE.md slot, ahead of the ./.claude/CLAUDE.md alternate and the locals. */
|
|
659
|
+
function siblingClaudeMdAddition(siblings: Array<{ path: string; content: string }>, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
660
|
+
let addition = ''
|
|
661
|
+
for (const sibling of siblings) {
|
|
662
|
+
if (sibling.content.trim().length === 0) continue
|
|
663
|
+
announce({ file_path: sibling.path, memory_type: memoryTypeForPath(sibling.path, home, projectRoot), load_reason: 'session_start' })
|
|
664
|
+
addition += `\n\n${instructionsBlock(sibling.path, sibling.content.trim())}`
|
|
665
|
+
}
|
|
666
|
+
return addition
|
|
667
|
+
}
|
|
668
|
+
|
|
508
669
|
/** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
|
|
509
670
|
* announced as it is added (only when non-empty, matching what reaches the prompt).
|
|
510
671
|
* Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
|
|
@@ -543,6 +704,19 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
|
|
|
543
704
|
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
544
705
|
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
545
706
|
* was imported. */
|
|
707
|
+
/** The refusal notice: every existing file an @import named that its importer may not
|
|
708
|
+
* reach. Claude asks about these through an approval dialog and loads the ones you
|
|
709
|
+
* allow; pi-code refuses them, and this is what says so. Silence was the real defect:
|
|
710
|
+
* the importing file looks loaded and its instructions are simply not there. */
|
|
711
|
+
function refusedImportsAddition(refused: Set<string>): string {
|
|
712
|
+
if (refused.size === 0) return ''
|
|
713
|
+
const list = [...refused]
|
|
714
|
+
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
715
|
+
.map((file) => `- ${file}`)
|
|
716
|
+
.join('\n')
|
|
717
|
+
return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
|
|
718
|
+
}
|
|
719
|
+
|
|
546
720
|
function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
547
721
|
if (imported.length === 0) return ''
|
|
548
722
|
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
@@ -621,9 +795,19 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
621
795
|
// natively but not this one; repo-controlled, so it is approval-gated like the
|
|
622
796
|
// locals and read once at session start.
|
|
623
797
|
let projectDotClaude: { path: string; content: string } | undefined
|
|
798
|
+
// Whether a file that is itself the thing to gate may be read. Decided at session
|
|
799
|
+
// start like the flag below, from the session context, which is where the trust
|
|
800
|
+
// capability lives; a tool_result context carries no approval state.
|
|
801
|
+
let gatedFilesApproved = false
|
|
624
802
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
625
803
|
// start with the silent check, so no prompt fires mid-flight.
|
|
626
804
|
let projectApproved = false
|
|
805
|
+
// Every context file the last turn put in the system prompt, so a nested file's
|
|
806
|
+
// @import cannot pay for a body that is already there.
|
|
807
|
+
let launchLoadedPaths: string[] = []
|
|
808
|
+
// Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
|
|
809
|
+
// read in the same subtree does not repeat them.
|
|
810
|
+
const nestedLoaded = new Set<string>()
|
|
627
811
|
// Instruction loads already announced on the shared bus, keyed reason:path.
|
|
628
812
|
// before_agent_start fires every turn, so without this a configured
|
|
629
813
|
// InstructionsLoaded hook would fire once per file per turn.
|
|
@@ -668,6 +852,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
668
852
|
memoKey: string,
|
|
669
853
|
native: Array<{ path: string; content: string }>,
|
|
670
854
|
contextFiles: Array<{ path: string; content: string }>,
|
|
855
|
+
siblings: Array<{ path: string; content: string }>,
|
|
671
856
|
home: string,
|
|
672
857
|
cwd: string,
|
|
673
858
|
excluded: (absPath: string) => boolean,
|
|
@@ -680,7 +865,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
680
865
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
681
866
|
// The user, project-.claude and managed-file additions join the seed too, so a
|
|
682
867
|
// context file's @import cannot pull any of them in a second time.
|
|
683
|
-
const ownPaths = [...native, ...localContexts].map((file) => file.path)
|
|
868
|
+
const ownPaths = [...native, ...localContexts, ...siblings].map((file) => file.path)
|
|
684
869
|
if (userContext !== undefined) ownPaths.push(userContext.path)
|
|
685
870
|
if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
|
|
686
871
|
ownPaths.push(managedClaudeMdPath())
|
|
@@ -757,6 +942,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
757
942
|
}
|
|
758
943
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
759
944
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
945
|
+
gatedFilesApproved = isGatedFileApproved(ctx)
|
|
760
946
|
})
|
|
761
947
|
|
|
762
948
|
pi.on('before_agent_start', async (event) => {
|
|
@@ -800,33 +986,75 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
800
986
|
|
|
801
987
|
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
802
988
|
|
|
989
|
+
// Repo-controlled text, so gated on the same approval as the locals and the
|
|
990
|
+
// ./.claude/CLAUDE.md; read here rather than at session start because only the
|
|
991
|
+
// turn's contextFiles say which file pi actually chose per directory.
|
|
992
|
+
const keptSiblings = (projectApproved ? siblingClaudeMdFiles(native) : []).filter((sibling) => !excluded(sibling.path)).map((sibling) => ({ path: sibling.path, content: stripBlockComments(sibling.content) }))
|
|
993
|
+
|
|
803
994
|
// ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
|
|
804
995
|
// loads it too there is no double block, then exclude-checked and comment-stripped
|
|
805
996
|
// like the rest. Its @imports resolve at project roots (rootsForImporter).
|
|
806
997
|
const nativeReal = new Set(realRoots(native.map((file) => file.path)))
|
|
807
998
|
const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
|
|
808
|
-
const
|
|
999
|
+
const siblingReal = new Set(realRoots(keptSiblings.map((sibling) => sibling.path)))
|
|
1000
|
+
const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && (nativeReal.has(dotReal) || siblingReal.has(dotReal))) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
|
|
809
1001
|
|
|
810
1002
|
// The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
|
|
811
1003
|
// @imports resolve (each at roots scoped to it, via rootsForImporter); their own
|
|
812
1004
|
// bodies are placed separately, so expansion only surfaces what they import.
|
|
813
|
-
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
1005
|
+
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...keptSiblings, ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
814
1006
|
|
|
815
1007
|
// Everything the expansion depends on, hashed: a turn whose inputs match the memo
|
|
816
1008
|
// and whose recorded mtimes are unchanged reuses the previous expansion outright.
|
|
817
1009
|
const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
|
|
818
1010
|
const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
|
|
819
1011
|
|
|
820
|
-
const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
|
|
1012
|
+
const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, keptSiblings, home, cwd, excluded)
|
|
1013
|
+
launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
|
|
821
1014
|
|
|
822
1015
|
// Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
|
|
823
1016
|
// additions, ahead of the CLAUDE.local.md bodies.
|
|
824
|
-
let addition =
|
|
1017
|
+
let addition = siblingClaudeMdAddition(keptSiblings, home, projectRoot, announce)
|
|
1018
|
+
addition += projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
|
|
825
1019
|
addition += localContextAddition(keptLocals, announce)
|
|
826
1020
|
addition += additionalDirsAddition(extras, announce)
|
|
827
1021
|
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
1022
|
+
addition += refusedImportsAddition(budget.refused)
|
|
828
1023
|
if (!changed && addition.length === 0) return
|
|
829
1024
|
|
|
830
1025
|
return { systemPrompt: prompt + addition }
|
|
831
1026
|
})
|
|
1027
|
+
|
|
1028
|
+
// Claude's nested traversal: CLAUDE.md and CLAUDE.local.md below the working
|
|
1029
|
+
// directory are not loaded at launch but "are included when Claude reads files in
|
|
1030
|
+
// those subdirectories" (memory.md). pi's loader stops at cwd, so the whole
|
|
1031
|
+
// below-cwd range is missing; this attaches each one to the tool result that
|
|
1032
|
+
// touched its directory, which is the same seam claude-rules uses for a scoped
|
|
1033
|
+
// rule. Once per file per session, ordered shallowest first so the deepest
|
|
1034
|
+
// instructions are read last, matching the launch-time ordering.
|
|
1035
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
1036
|
+
const rel = fileToolTarget(event)
|
|
1037
|
+
if (rel === undefined) return
|
|
1038
|
+
// Repo-controlled text, gated like every other project file this extension adds.
|
|
1039
|
+
if (!projectApproved) return
|
|
1040
|
+
// Realpath both sides: the walk below is lexical, so a symlinked subdirectory
|
|
1041
|
+
// would otherwise carry it straight out of the project.
|
|
1042
|
+
const [realCwd = ctx.cwd] = realRoots([ctx.cwd])
|
|
1043
|
+
const touched = path.resolve(ctx.cwd, rel)
|
|
1044
|
+
// The directory, not the file: a write creates its target, and a read of a path
|
|
1045
|
+
// that has since moved should still contribute what its directory holds.
|
|
1046
|
+
const [touchedDir] = realRoots([path.dirname(touched)])
|
|
1047
|
+
if (touchedDir === undefined) return
|
|
1048
|
+
|
|
1049
|
+
const home = os.homedir()
|
|
1050
|
+
const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
1051
|
+
const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
|
|
1052
|
+
const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
|
|
1053
|
+
const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
|
|
1054
|
+
for (const loaded of events) announce(loaded)
|
|
1055
|
+
if (bodies.length === 0) return
|
|
1056
|
+
// Capped like every other tool output: one read in a deep subtree must not be
|
|
1057
|
+
// able to spend the context window on memory files.
|
|
1058
|
+
return { content: [...event.content, { type: 'text' as const, text: capForContext(bodies.join('\n\n')) }] }
|
|
1059
|
+
})
|
|
832
1060
|
}
|
|
@@ -36,15 +36,11 @@ import * as fs from 'node:fs'
|
|
|
36
36
|
import * as os from 'node:os'
|
|
37
37
|
import * as path from 'node:path'
|
|
38
38
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
39
|
-
|
|
40
39
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
41
40
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
42
41
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
43
42
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
44
|
-
|
|
45
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
47
|
-
}
|
|
43
|
+
import { isRecord } from './internal/values.js'
|
|
48
44
|
|
|
49
45
|
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
50
46
|
* as-is, a number or boolean becomes its String() form, and anything else (object,
|
|
@@ -19,6 +19,7 @@ import * as os from 'node:os'
|
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
21
21
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
22
|
+
import { contentText, errorMessage } from './internal/values.js'
|
|
22
23
|
|
|
23
24
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
24
25
|
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
@@ -116,17 +117,8 @@ function rememberWorkTree(shadowDir: string, cwd: string): void {
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function extractText(content: unknown): string {
|
|
120
|
-
if (typeof content === 'string') return content
|
|
121
|
-
if (!Array.isArray(content)) return ''
|
|
122
|
-
return content
|
|
123
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
124
|
-
.map((part) => part.text)
|
|
125
|
-
.join(' ')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
120
|
function promptSnippet(content: unknown): string {
|
|
129
|
-
const text =
|
|
121
|
+
const text = contentText(content, ' ').replace(/\s+/g, ' ').trim()
|
|
130
122
|
if (text.length <= PROMPT_SNIPPET_LENGTH) return text
|
|
131
123
|
return `${text.slice(0, PROMPT_SNIPPET_LENGTH)}…`
|
|
132
124
|
}
|
|
@@ -156,7 +148,7 @@ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string
|
|
|
156
148
|
if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
|
|
157
149
|
return true
|
|
158
150
|
} catch (error) {
|
|
159
|
-
const message =
|
|
151
|
+
const message = errorMessage(error)
|
|
160
152
|
ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
|
|
161
153
|
return false
|
|
162
154
|
}
|
|
@@ -237,7 +229,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
237
229
|
} catch (error) {
|
|
238
230
|
// Without the mirror, files the user excluded locally are snapshotted into the
|
|
239
231
|
// checkpoint store and restored by /rewind, so this is not a silent fallback.
|
|
240
|
-
ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${
|
|
232
|
+
ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${errorMessage(error)}`, 'warning')
|
|
241
233
|
}
|
|
242
234
|
}
|
|
243
235
|
|
package/extensions/goal.ts
CHANGED
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
|
|
38
38
|
import * as os from 'node:os'
|
|
39
39
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
40
|
-
|
|
41
40
|
import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
|
|
42
41
|
import {
|
|
43
42
|
checkinIntervalMs,
|
|
@@ -66,6 +65,7 @@ import { completeText } from './internal/model-complete.js'
|
|
|
66
65
|
import { resolveModelOverride } from './internal/model-lookup.js'
|
|
67
66
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
68
67
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
68
|
+
import { errorMessage } from './internal/values.js'
|
|
69
69
|
|
|
70
70
|
/** Session entry type the goal state persists under, and the custom message type its
|
|
71
71
|
* transcript lines (kickoff, verdicts, check-ins) carry. */
|
|
@@ -340,7 +340,7 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
340
340
|
// A user interrupt is not an evaluator failure: the goal stays, nothing to say.
|
|
341
341
|
if (ctx.signal?.aborted) return
|
|
342
342
|
// No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
|
|
343
|
-
if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${
|
|
343
|
+
if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${errorMessage(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
|
|
344
344
|
return
|
|
345
345
|
}
|
|
346
346
|
// Interrupted, cleared, or replaced during the await: this verdict must not act.
|
|
@@ -8,7 +8,8 @@ import * as fs from 'node:fs'
|
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
10
10
|
import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
|
|
11
|
-
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
+
import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
|
|
12
|
+
import { errorMessage, isRecord } from '../internal/values.js'
|
|
12
13
|
|
|
13
14
|
export interface HookCommand {
|
|
14
15
|
type?: string
|
|
@@ -67,10 +68,6 @@ export function isBackgroundHook(hook: HookCommand): boolean {
|
|
|
67
68
|
}
|
|
68
69
|
export type HooksConfig = Record<string, HookMatcher[]>
|
|
69
70
|
|
|
70
|
-
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
71
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
71
|
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
75
72
|
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
76
73
|
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
@@ -92,13 +89,8 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
|
|
|
92
89
|
* disableAllHooks cannot disable hooks configured through managed policy settings,
|
|
93
90
|
* so the caller keeps managed hooks running when only this half is set. */
|
|
94
91
|
export function readSettingsDisableAllHooks(files: string[]): boolean {
|
|
95
|
-
for (const
|
|
96
|
-
|
|
97
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
98
|
-
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
99
|
-
} catch {
|
|
100
|
-
// missing or invalid file: skip
|
|
101
|
-
}
|
|
92
|
+
for (const settings of readSettingsChain(files)) {
|
|
93
|
+
if (settings.disableAllHooks === true) return true
|
|
102
94
|
}
|
|
103
95
|
return false
|
|
104
96
|
}
|
|
@@ -152,14 +144,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
|
|
|
152
144
|
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
153
145
|
}
|
|
154
146
|
collect(managed.allowedHttpHookUrls)
|
|
155
|
-
for (const
|
|
156
|
-
try {
|
|
157
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
158
|
-
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
159
|
-
} catch {
|
|
160
|
-
// missing or invalid file: skip
|
|
161
|
-
}
|
|
162
|
-
}
|
|
147
|
+
for (const settings of readSettingsChain(files)) collect(settings.allowedHttpHookUrls)
|
|
163
148
|
return found
|
|
164
149
|
}
|
|
165
150
|
|
|
@@ -203,7 +188,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
203
188
|
} catch (error) {
|
|
204
189
|
// Every hook this source declares is now absent, a policy hook among them, so the
|
|
205
190
|
// failure is named rather than left to look like a file with no hooks in it.
|
|
206
|
-
console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${
|
|
191
|
+
console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${errorMessage(error)}`)
|
|
207
192
|
return
|
|
208
193
|
}
|
|
209
194
|
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
8
8
|
import type { PathAnchors } from '../internal/path-rules.js'
|
|
9
|
+
import { errorMessage, isRecord } from '../internal/values.js'
|
|
9
10
|
import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
|
|
10
|
-
import {
|
|
11
|
+
import type { HookCommand, HooksConfig } from './config.js'
|
|
11
12
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
12
13
|
import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
|
|
13
14
|
|
|
@@ -63,7 +64,7 @@ export function hookJsonError(text: string): string | undefined {
|
|
|
63
64
|
JSON.parse(trimmed)
|
|
64
65
|
return undefined
|
|
65
66
|
} catch (error) {
|
|
66
|
-
parseError =
|
|
67
|
+
parseError = errorMessage(error)
|
|
67
68
|
}
|
|
68
69
|
const lines = trimmed
|
|
69
70
|
.split('\n')
|