pi-code 1.0.8 → 1.0.10
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 +33 -29
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +284 -51
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +108 -18
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +60 -3
- package/extensions/mcp.ts +186 -41
- package/extensions/memory.ts +118 -4
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +3 -2
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +19 -6
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
|
@@ -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
|
@@ -62,6 +62,7 @@ import * as path from 'node:path'
|
|
|
62
62
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
63
63
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
64
64
|
import { runAgent } from './internal/agent-run.js'
|
|
65
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
65
66
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
66
67
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
67
68
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
@@ -83,6 +84,9 @@ const DEFAULT_TIMEOUT_S = 60
|
|
|
83
84
|
interface HookCommand {
|
|
84
85
|
type?: string
|
|
85
86
|
command: string
|
|
87
|
+
/** exec-form: spawn `command` directly with these args and no shell (shell-form when
|
|
88
|
+
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
89
|
+
args?: string[]
|
|
86
90
|
timeout?: number
|
|
87
91
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
88
92
|
url?: string
|
|
@@ -122,14 +126,15 @@ export interface HookRunResult {
|
|
|
122
126
|
}
|
|
123
127
|
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
124
128
|
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
125
|
-
/** The shell path specifically; the statusline reuses it for its own command.
|
|
126
|
-
|
|
129
|
+
/** The shell path specifically; the statusline reuses it for its own command. With an
|
|
130
|
+
* `args` array it becomes the exec path: `command` is spawned directly with those args. */
|
|
131
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
|
|
127
132
|
|
|
128
133
|
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
129
134
|
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
130
135
|
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
131
136
|
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
132
|
-
const files = [path.join(home, '
|
|
137
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
133
138
|
if (!trusted) return files
|
|
134
139
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
135
140
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -260,16 +265,24 @@ function foldName(name: string): string {
|
|
|
260
265
|
return name.toLowerCase().replaceAll('-', '_')
|
|
261
266
|
}
|
|
262
267
|
|
|
263
|
-
|
|
264
|
-
|
|
268
|
+
/** A matcher string's compiled form: a set of folded exact names, or a regex. */
|
|
269
|
+
type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
|
|
270
|
+
|
|
271
|
+
function exactTokens(matcher: string): Set<string> {
|
|
272
|
+
return new Set(
|
|
265
273
|
matcher
|
|
266
274
|
.split(/[|,]/)
|
|
267
275
|
.map((token) => foldName(token.trim()))
|
|
268
276
|
.filter(Boolean),
|
|
269
277
|
)
|
|
270
|
-
return names.some((name) => tokens.has(foldName(name)))
|
|
271
278
|
}
|
|
272
279
|
|
|
280
|
+
/** Hook config is static per session and dispatch consults every matcher on every
|
|
281
|
+
* event, so each matcher string compiles once. Matchers are few; the bound is a
|
|
282
|
+
* safety net, clearing the (cheap to rebuild) cache rather than evicting. */
|
|
283
|
+
const compiledMatchers = new Map<string, CompiledMatcher>()
|
|
284
|
+
const COMPILED_MATCHER_BOUND = 1000
|
|
285
|
+
|
|
273
286
|
/** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
|
|
274
287
|
* is reported by name and skipped, so one bad entry costs its own hooks, not the
|
|
275
288
|
* session's tool calls. */
|
|
@@ -290,15 +303,48 @@ function isUsableMatcher(entry: unknown, file: string, event: string): entry is
|
|
|
290
303
|
return true
|
|
291
304
|
}
|
|
292
305
|
|
|
306
|
+
let matcherCompiles = 0
|
|
307
|
+
|
|
308
|
+
/** Test seam: matcher compilations performed, for asserting memoization. */
|
|
309
|
+
export function matcherCompileCount(): number {
|
|
310
|
+
return matcherCompiles
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Test seam: drop compiled matchers so a test observes fresh compiles. */
|
|
314
|
+
export function resetMatcherCache(): void {
|
|
315
|
+
compiledMatchers.clear()
|
|
316
|
+
matcherCompiles = 0
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function compileMatcher(matcher: string): CompiledMatcher {
|
|
320
|
+
const cached = compiledMatchers.get(matcher)
|
|
321
|
+
if (cached !== undefined) return cached
|
|
322
|
+
matcherCompiles += 1
|
|
323
|
+
let compiled: CompiledMatcher
|
|
324
|
+
if (EXACT_MATCHER.test(matcher)) {
|
|
325
|
+
compiled = { tokens: exactTokens(matcher) }
|
|
326
|
+
} else {
|
|
327
|
+
try {
|
|
328
|
+
compiled = { regex: new RegExp(matcher, 'i') }
|
|
329
|
+
} catch {
|
|
330
|
+
// An invalid regex matcher falls back to exact-name matching, as before.
|
|
331
|
+
compiled = { tokens: exactTokens(matcher) }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
|
|
335
|
+
compiledMatchers.set(matcher, compiled)
|
|
336
|
+
return compiled
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
|
|
294
340
|
if (!matcher || matcher === '*') return true
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const regex =
|
|
341
|
+
const compiled = compileMatcher(matcher)
|
|
342
|
+
if ('regex' in compiled) {
|
|
343
|
+
const { regex } = compiled
|
|
298
344
|
return names.some((name) => regex.test(name))
|
|
299
|
-
} catch {
|
|
300
|
-
return exactListApplies(matcher, names)
|
|
301
345
|
}
|
|
346
|
+
const { tokens } = compiled
|
|
347
|
+
return names.some((name) => tokens.has(foldName(name)))
|
|
302
348
|
}
|
|
303
349
|
|
|
304
350
|
/** A hook entry pi-code can run: a shell command, an http POST, an in-process
|
|
@@ -431,14 +477,23 @@ function killTree(child: ChildProcess): void {
|
|
|
431
477
|
child.kill('SIGKILL')
|
|
432
478
|
}
|
|
433
479
|
|
|
434
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
|
|
480
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
|
|
435
481
|
new Promise((resolve) => {
|
|
436
482
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
437
483
|
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
438
484
|
// the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
439
|
-
// reference project files regardless of the shell's cwd.
|
|
440
|
-
|
|
441
|
-
const
|
|
485
|
+
// reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
|
|
486
|
+
// subprocess Claude spawns, so it is set on the child unconditionally.
|
|
487
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
|
|
488
|
+
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
489
|
+
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
490
|
+
// and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
|
|
491
|
+
// each arg is replaced with the event JSON by a replacer function (so $$/$& in the
|
|
492
|
+
// payload survive verbatim). Without args it stays the shell path. Both share the
|
|
493
|
+
// same detached process group, so killTree reaches the descendants either way.
|
|
494
|
+
const file = Array.isArray(args) ? command : '/bin/sh'
|
|
495
|
+
const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
|
|
496
|
+
const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
442
497
|
let stdout = ''
|
|
443
498
|
let stderr = ''
|
|
444
499
|
let settled = false
|
|
@@ -632,6 +687,18 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
|
|
|
632
687
|
return ''
|
|
633
688
|
}
|
|
634
689
|
|
|
690
|
+
/** Claude overrides a Stop hook after it blocks this many times in a row with no user
|
|
691
|
+
* progress, ending the turn with a warning rather than looping forever. */
|
|
692
|
+
const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
|
|
693
|
+
|
|
694
|
+
/** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
|
|
695
|
+
* is a positive integer, else the default. A non-positive or malformed value falls back
|
|
696
|
+
* to the default rather than capping at zero (which would suppress the very first block). */
|
|
697
|
+
export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
|
|
698
|
+
const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
|
|
699
|
+
return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
700
|
+
}
|
|
701
|
+
|
|
635
702
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
636
703
|
const MAX_TIMEOUT_S = 2_147_483
|
|
637
704
|
|
|
@@ -792,6 +859,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
792
859
|
let allowedHttpHookUrls: string[] | undefined
|
|
793
860
|
let pendingSessionContext: string[] = []
|
|
794
861
|
let stopHookActive = false
|
|
862
|
+
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
863
|
+
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
864
|
+
let stopHookBlockCount = 0
|
|
795
865
|
let sessionCtx: ExtensionContext | undefined
|
|
796
866
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
797
867
|
let hooksDisabled = false
|
|
@@ -815,7 +885,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
815
885
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
816
886
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
817
887
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
818
|
-
return runHookCommand(hook.command, merged, ms, projectDir)
|
|
888
|
+
return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
|
|
819
889
|
}
|
|
820
890
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
821
891
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
@@ -955,6 +1025,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
955
1025
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
956
1026
|
// prompts the user submitted.
|
|
957
1027
|
if (event.source === 'extension') return { action: 'continue' }
|
|
1028
|
+
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
1029
|
+
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
1030
|
+
stopHookBlockCount = 0
|
|
958
1031
|
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
959
1032
|
if (decision.block) {
|
|
960
1033
|
// pi's input result has no reason channel, so surface why before consuming it.
|
|
@@ -1007,8 +1080,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
1007
1080
|
return { block: false, reason: '' }
|
|
1008
1081
|
})
|
|
1009
1082
|
.find((verdict) => verdict.block)
|
|
1010
|
-
|
|
1011
|
-
|
|
1083
|
+
if (!block) {
|
|
1084
|
+
// A non-blocking Stop breaks the streak: the next block starts a fresh count.
|
|
1085
|
+
stopHookActive = false
|
|
1086
|
+
stopHookBlockCount = 0
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
stopHookBlockCount += 1
|
|
1090
|
+
const cap = stopHookBlockCap()
|
|
1091
|
+
if (stopHookBlockCount >= cap) {
|
|
1092
|
+
// Claude overrides a Stop hook that has blocked cap times in a row with no user
|
|
1093
|
+
// progress: suppress the continuation, warn, and let the turn end so the loop cannot
|
|
1094
|
+
// run forever. Reset the count so a later run (or user turn) starts clean.
|
|
1095
|
+
stopHookActive = false
|
|
1096
|
+
stopHookBlockCount = 0
|
|
1097
|
+
ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
stopHookActive = true
|
|
1101
|
+
pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
|
|
1012
1102
|
})
|
|
1013
1103
|
|
|
1014
1104
|
pi.on('session_before_compact', async (event, ctx) => {
|
|
@@ -35,7 +35,16 @@ export interface ParsedCommand {
|
|
|
35
35
|
* command's injected spans run (see spanExec). */
|
|
36
36
|
shell?: string
|
|
37
37
|
model?: string
|
|
38
|
+
/** `effort:` per-command thinking-level override, one of pi's ThinkingLevel values
|
|
39
|
+
* (off/minimal/low/medium/high/xhigh/max); undefined when absent or unrecognized. */
|
|
40
|
+
effort?: string
|
|
41
|
+
/** `when_to_use:` extra trigger text appended to the slash_command tool listing only,
|
|
42
|
+
* never to the user-facing command description. */
|
|
43
|
+
whenToUse?: string
|
|
38
44
|
disableModelInvocation: boolean
|
|
45
|
+
/** `user-invocable:` false hides the command from the slash-command surface while
|
|
46
|
+
* keeping it callable by the model through the slash_command tool. Default true. */
|
|
47
|
+
userInvocable: boolean
|
|
39
48
|
body: string
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -236,6 +245,15 @@ const text = (value: unknown): string => {
|
|
|
236
245
|
const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
|
|
237
246
|
const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
|
|
238
247
|
|
|
248
|
+
/** YAML's negative boolean spellings, the mirror of YAML_TRUE. A flag that defaults to
|
|
249
|
+
* true (user-invocable) is turned off only by one of these; any other value, absent
|
|
250
|
+
* included, leaves it on, so an unrelated string never silently hides a command. */
|
|
251
|
+
const YAML_FALSE = new Set(['false', 'no', 'off', 'n', '0'])
|
|
252
|
+
const isFlagDisabled = (value: unknown): boolean => value === false || YAML_FALSE.has(text(value).toLowerCase())
|
|
253
|
+
|
|
254
|
+
/** pi's ThinkingLevel union, the values a command's `effort:` override may name. */
|
|
255
|
+
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
256
|
+
|
|
239
257
|
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
240
258
|
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
241
259
|
|
|
@@ -265,6 +283,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
265
283
|
const disable = frontmatter['disable-model-invocation']
|
|
266
284
|
const grants = parseToolGrants(frontmatter['allowed-tools'])
|
|
267
285
|
const shell = text(frontmatter.shell).toLowerCase()
|
|
286
|
+
const effort = text(frontmatter.effort).toLowerCase()
|
|
268
287
|
return {
|
|
269
288
|
description: text(frontmatter.description) || firstLine.slice(0, 60),
|
|
270
289
|
argumentHint: hint(frontmatter['argument-hint']) || undefined,
|
|
@@ -276,7 +295,11 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
276
295
|
disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
|
|
277
296
|
shell: SHELLS.has(shell) ? shell : undefined,
|
|
278
297
|
model: text(frontmatter.model) || undefined,
|
|
298
|
+
// An unrecognized effort is dropped rather than passed to setThinkingLevel.
|
|
299
|
+
effort: THINKING_LEVELS.has(effort) ? effort : undefined,
|
|
300
|
+
whenToUse: text(frontmatter.when_to_use) || undefined,
|
|
279
301
|
disableModelInvocation: isFlagEnabled(disable),
|
|
302
|
+
userInvocable: !isFlagDisabled(frontmatter['user-invocable']),
|
|
280
303
|
body,
|
|
281
304
|
}
|
|
282
305
|
}
|
|
@@ -453,7 +476,9 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
|
|
|
453
476
|
if (shell === 'powershell') {
|
|
454
477
|
const binary = resolveBinary()
|
|
455
478
|
if (binary !== undefined) {
|
|
456
|
-
|
|
479
|
+
// CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
|
|
480
|
+
// it is exported in the script alongside CLAUDE_PROJECT_DIR.
|
|
481
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
457
482
|
// No in-script 2>&1: under pwsh 7 it does not merge a native command's
|
|
458
483
|
// stderr on a script block, so mergeStreams has the caller append it. The
|
|
459
484
|
// trailing exit forwards a failed native command's code, which pwsh
|
|
@@ -470,7 +495,7 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
|
|
|
470
495
|
// comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
471
496
|
// invocation, and `:` keeps such a span the harmless no-op it was on HEAD
|
|
472
497
|
// while the group still merges stderr for real spans.
|
|
473
|
-
return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
|
|
498
|
+
return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
474
499
|
}
|
|
475
500
|
|
|
476
501
|
interface FenceBlock {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE_CONFIG_DIR: Claude Code's override for the home configuration directory.
|
|
3
|
+
*
|
|
4
|
+
* Claude relocates the entire ~/.claude configuration tree (settings.json, commands,
|
|
5
|
+
* agents, skills, plugins, output-styles, CLAUDE.md) when CLAUDE_CONFIG_DIR is set,
|
|
6
|
+
* so a user can keep that config outside their home directory. This resolves the
|
|
7
|
+
* home-scope config root for every consumer; a project's own `.claude/` directory is
|
|
8
|
+
* a separate scope and is never affected. A leading `~` expands against `home`, and
|
|
9
|
+
* the result is resolved to an absolute path so a relative value cannot depend on the
|
|
10
|
+
* reader's working directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** The home-scope Claude config directory: CLAUDE_CONFIG_DIR (expanded, absolute)
|
|
16
|
+
* when set to a non-empty value, otherwise `<home>/.claude`. */
|
|
17
|
+
export function claudeConfigDir(home: string): string {
|
|
18
|
+
const override = process.env.CLAUDE_CONFIG_DIR
|
|
19
|
+
if (override && override.trim().length > 0) {
|
|
20
|
+
const expanded = override.startsWith('~') ? path.join(home, override.slice(1)) : override
|
|
21
|
+
return path.resolve(expanded)
|
|
22
|
+
}
|
|
23
|
+
return path.join(home, '.claude')
|
|
24
|
+
}
|
|
@@ -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 {
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import * as fs from 'node:fs'
|
|
16
16
|
import * as path from 'node:path'
|
|
17
17
|
|
|
18
|
+
import { claudeConfigDir } from './config-dir.js'
|
|
19
|
+
|
|
18
20
|
export interface InstalledPlugin {
|
|
19
21
|
name: string
|
|
20
22
|
/** The version directory: ${CLAUDE_PLUGIN_ROOT}. */
|
|
@@ -90,16 +92,70 @@ function pluginConfigsMap(settingsFiles: string[]): Record<string, Record<string
|
|
|
90
92
|
return merged
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** Memoized discovery per (home, extra settings files), revalidated by fingerprint. */
|
|
96
|
+
const pluginCache = new Map<string, { fingerprint: string; plugins: InstalledPlugin[] }>()
|
|
97
|
+
|
|
98
|
+
/** Drop every memoized discovery; the next installedPlugins call walks afresh. */
|
|
99
|
+
export function resetInstalledPluginsCache(): void {
|
|
100
|
+
pluginCache.clear()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** mtime plus size, so a same-instant rewrite with different content still differs. */
|
|
104
|
+
function statToken(target: string): string {
|
|
105
|
+
try {
|
|
106
|
+
const stat = fs.statSync(target)
|
|
107
|
+
return `${stat.mtimeMs}:${stat.size}`
|
|
108
|
+
} catch {
|
|
109
|
+
return 'missing'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A cheap change signature for one home's plugin config: the settings files' stat
|
|
115
|
+
* tokens plus the cache tree's directory names and mtimes down through each plugin's
|
|
116
|
+
* version directories, and the stat token of the resolved (newest) version's manifest
|
|
117
|
+
* so an in-place edit of it invalidates the cache. Costs a few stats where the full
|
|
118
|
+
* walk reads and parses the settings and every manifest.
|
|
119
|
+
*/
|
|
120
|
+
function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
|
|
121
|
+
const parts = settingsFiles.map(statToken)
|
|
122
|
+
for (const marketplace of listDirs(cacheDir)) {
|
|
123
|
+
const marketplaceDir = path.join(cacheDir, marketplace)
|
|
124
|
+
parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
|
|
125
|
+
for (const pluginDir of listDirs(marketplaceDir)) {
|
|
126
|
+
const pluginPath = path.join(marketplaceDir, pluginDir)
|
|
127
|
+
parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
|
|
128
|
+
const versions = listDirs(pluginPath)
|
|
129
|
+
for (const version of versions) {
|
|
130
|
+
parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
|
|
131
|
+
}
|
|
132
|
+
// resolvePlugin reads only the newest version's manifest, so its stat token is
|
|
133
|
+
// what an in-place edit (no directory entry changing) must move.
|
|
134
|
+
const newest = newestVersion(versions)
|
|
135
|
+
if (newest) parts.push(`${marketplace}/${pluginDir}/${newest}/manifest:${statToken(path.join(pluginPath, newest, '.claude-plugin', 'plugin.json'))}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return parts.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
93
141
|
/**
|
|
94
142
|
* Enabled plugins from the cache. Enablement is decided by the user's own
|
|
95
143
|
* settings only: plugins install to the user's machine and carry code (hook
|
|
96
144
|
* scripts, MCP server commands), so a checked-out repo must not be able to flip
|
|
97
145
|
* which of them run. `extraSettingsFiles`, when given, are additional
|
|
98
146
|
* user-controlled settings sources, not project files.
|
|
147
|
+
*
|
|
148
|
+
* Several extensions call this at session start and per discovery, so the walk
|
|
149
|
+
* is memoized behind the fingerprint above; callers always see current data
|
|
150
|
+
* because any settings edit or cache-tree change invalidates it.
|
|
99
151
|
*/
|
|
100
152
|
export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
|
|
101
|
-
const cacheDir = path.join(home, '
|
|
102
|
-
const settingsFiles = [path.join(home, '
|
|
153
|
+
const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
|
|
154
|
+
const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
|
|
155
|
+
const key = [home, ...extraSettingsFiles].join('\n')
|
|
156
|
+
const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
|
|
157
|
+
const cached = pluginCache.get(key)
|
|
158
|
+
if (cached?.fingerprint === fingerprint) return cached.plugins
|
|
103
159
|
const enabled = enabledMap(settingsFiles)
|
|
104
160
|
const configs = pluginConfigsMap(settingsFiles)
|
|
105
161
|
const plugins: InstalledPlugin[] = []
|
|
@@ -109,6 +165,7 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
|
|
|
109
165
|
if (plugin) plugins.push(plugin)
|
|
110
166
|
}
|
|
111
167
|
}
|
|
168
|
+
pluginCache.set(key, { fingerprint, plugins })
|
|
112
169
|
return plugins
|
|
113
170
|
}
|
|
114
171
|
|
|
@@ -125,7 +182,7 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
|
|
|
125
182
|
const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
|
|
126
183
|
const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
|
|
127
184
|
const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
|
|
128
|
-
return { name, root, dataDir: path.join(home, '
|
|
185
|
+
return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
|
|
129
186
|
}
|
|
130
187
|
|
|
131
188
|
/** The two plugin path variables, textually substituted into plugin-shipped
|