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.
@@ -21,8 +21,9 @@ import * as os from 'node:os'
21
21
  import * as path from 'node:path'
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
23
 
24
+ import { claudeConfigDir } from './internal/config-dir.js'
24
25
  import { publishInstructionLoad } from './internal/instruction-events.js'
25
- import { globToRegExpSource } from './internal/path-rules.js'
26
+ import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
26
27
  import { isProjectApproved } from './internal/project-approval.js'
27
28
  import { findNearestDir } from './internal/project-root.js'
28
29
  import { stripBlockComments } from './internal/strip-comments.js'
@@ -81,19 +82,7 @@ export function parseFrontmatter(content: string): Frontmatter {
81
82
  * the rule set's root.
82
83
  */
83
84
  export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
84
- const posix = relPath.split(path.sep).join('/')
85
- const base = posix.split('/').pop() ?? posix
86
- return globs.some((raw) => {
87
- let glob = raw.trim()
88
- if (!glob) return false
89
- if (glob.startsWith('./')) glob = glob.slice(2)
90
- else if (glob.startsWith('/')) glob = glob.slice(1)
91
- // A trailing slash means the directory's contents, like gitignore; `docs/` alone
92
- // would compile to `^docs/$` and match nothing.
93
- if (glob.endsWith('/')) glob += '**'
94
- const target = glob.includes('/') ? posix : base
95
- return new RegExp(`^${globToRegExpSource(glob)}$`).test(target)
96
- })
85
+ return matchesCompiledGlobs(relPath, compileGlobs(globs))
97
86
  }
98
87
 
99
88
  /** A rule pointer line, annotated with its path scope when present. */
@@ -199,8 +188,10 @@ function rulesSection(title: string, rules: RuleSet, base: string): string {
199
188
 
200
189
  /** A scoped rule resolved to the root its globs match against, ready to attach. */
201
190
  interface AttachTarget {
202
- key: string
191
+ /** The rule's `paths:` globs as written, reported on the instruction-events bus. */
203
192
  globs: string[]
193
+ /** The globs precompiled once at session start for the per-tool-result scan. */
194
+ compiled: CompiledGlob[]
204
195
  body: string
205
196
  /** The absolute directory `paths:` globs are matched relative to. */
206
197
  root: string
@@ -210,18 +201,26 @@ interface AttachTarget {
210
201
  memoryType: 'User' | 'Project'
211
202
  }
212
203
 
204
+ // Module level because the working list lives in each extension instance's closure.
205
+ let pendingScopedRules = 0
206
+
207
+ /** Test seam: scoped rules still awaiting attachment in the current session, for
208
+ * asserting that a fully attached rule leaves the per-tool-result working list. */
209
+ export function pendingScopedRuleCount(): number {
210
+ return pendingScopedRules
211
+ }
212
+
213
213
  export default function claudeRulesExtension(pi: ExtensionAPI) {
214
- const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
214
+ const globalRulesDir = path.join(claudeConfigDir(os.homedir()), 'rules')
215
215
  let globalRules: RuleSet = EMPTY_RULES
216
216
  let projectRules: RuleSet = EMPTY_RULES
217
217
  // The base a scoped-rule pointer is written against, so the model's read resolves.
218
218
  // The project rules dir may sit at an ancestor of cwd, where a cwd-relative
219
219
  // '.claude/rules' would point the read at a path that does not exist.
220
220
  let projectRulesBase = '.claude/rules'
221
- // Scoped rules ready to attach when a matching file is touched, and the set of
222
- // rules already attached this session so each attaches at most once.
221
+ // Scoped rules still awaiting a matching touch. An attached rule leaves the
222
+ // list, so each attaches at most once and the per-tool-result scan shrinks.
223
223
  let attachTargets: AttachTarget[] = []
224
- const attached = new Set<string>()
225
224
 
226
225
  pi.on('session_start', async (_event, ctx) => {
227
226
  globalRules = readRules(globalRulesDir)
@@ -236,13 +235,14 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
236
235
 
237
236
  // Global globs are relative to cwd; project globs to the project root (the dir
238
237
  // holding .claude), so `db/**` in a repo rule matches repo-relative paths even
239
- // from a subdirectory session. Reset per session so a re-run re-attaches.
240
- attached.clear()
238
+ // from a subdirectory session. Globs compile here, once per session, rather
239
+ // than on every tool result; rebuilt per session so a re-run re-attaches.
241
240
  const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
242
241
  attachTargets = [
243
- ...globalRules.scoped.map((rule) => ({ key: `global:${rule.rel}`, globs: rule.paths, body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
244
- ...projectRules.scoped.map((rule) => ({ key: `project:${rule.rel}`, globs: rule.paths, body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
242
+ ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: ctx.cwd, file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
243
+ ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: projectRoot, file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
245
244
  ]
245
+ pendingScopedRules = attachTargets.length
246
246
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
247
247
  // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
248
248
  // would point at a nonexistent path under the subdirectory.
@@ -277,21 +277,25 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
277
277
  const abs = path.resolve(ctx.cwd, rel)
278
278
 
279
279
  const bodies: string[] = []
280
+ const remaining: AttachTarget[] = []
280
281
  for (const target of attachTargets) {
281
- if (attached.has(target.key)) continue
282
282
  const relativeToRoot = path.relative(target.root, abs)
283
283
  // A file outside the rule root cannot match its project-relative globs. Test for
284
284
  // a real parent-traversal segment, not a leading '..' (a file named `..config` is
285
285
  // inside the root).
286
- if (relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) continue
287
- if (!pathMatchesGlobs(relativeToRoot, target.globs)) continue
288
- attached.add(target.key)
286
+ const outsideRoot = relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)
287
+ if (outsideRoot || !matchesCompiledGlobs(relativeToRoot, target.compiled)) {
288
+ remaining.push(target)
289
+ continue
290
+ }
289
291
  bodies.push(target.body)
290
292
  // The lazy attach is Claude's path_glob_match instruction load; the hooks
291
- // extension bridges the bus event to the InstructionsLoaded hook. The
292
- // once-per-session attach set above also bounds the events to one per rule.
293
+ // extension bridges the bus event to the InstructionsLoaded hook. Leaving
294
+ // the working list also bounds the events to one per rule per session.
293
295
  publishInstructionLoad(pi.events, { file_path: target.file, memory_type: target.memoryType, load_reason: 'path_glob_match', globs: target.globs, trigger_file_path: abs })
294
296
  }
297
+ attachTargets = remaining
298
+ pendingScopedRules = attachTargets.length
295
299
  if (bodies.length === 0) return
296
300
  return { content: [...event.content, ...bodies.map((text) => ({ type: 'text' as const, text }))] }
297
301
  })
@@ -45,6 +45,7 @@ import { Type } from 'typebox'
45
45
 
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
47
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
+ import { claudeConfigDir } from './internal/config-dir.js'
48
49
  import { readManagedSettings } from './internal/managed-settings.js'
49
50
  import { capForContext } from './internal/output-guard.js'
50
51
  import { matchesPathRules } from './internal/path-rules.js'
@@ -115,7 +116,7 @@ function isDirectory(target: string): boolean {
115
116
  * directory is the nearest at or above cwd (bounded at the repository root, matching
116
117
  * the approval walk) and is included only for approved projects. */
117
118
  export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
118
- const candidates = [path.join(home, '.claude', 'commands')]
119
+ const candidates = [path.join(claudeConfigDir(home), 'commands')]
119
120
  if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'commands')) ?? path.join(cwd, '.claude', 'commands'))
120
121
  const dirs: string[] = []
121
122
  for (const dir of candidates) {
@@ -165,7 +166,7 @@ type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
165
166
  */
166
167
  export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
167
168
  if (readManagedSettings().disableSkillShellExecution === true) return true
168
- const files = [path.join(home, '.claude', 'settings.json')]
169
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
169
170
  if (trusted) {
170
171
  for (const name of ['settings.json', 'settings.local.json']) {
171
172
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -244,6 +245,8 @@ export interface SlashCommandEntry {
244
245
  name: string
245
246
  description: string
246
247
  argumentHint?: string
248
+ /** `when_to_use:` trigger text, appended to this entry's line in the tool listing. */
249
+ whenToUse?: string
247
250
  }
248
251
 
249
252
  /** One listed command's cap inside the tool description, as Claude cuts an
@@ -271,7 +274,10 @@ export function slashCommandToolDescription(commands: SlashCommandEntry[], budge
271
274
  let omitted = 0
272
275
  for (const command of commands) {
273
276
  const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
274
- const entry = `/${command.name} - ${command.description}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
277
+ // when_to_use is model-facing trigger text, appended after the description and before
278
+ // the argument hint; it shares the per-entry cap and never reaches the user surface.
279
+ const whenSuffix = command.whenToUse ? ` ${command.whenToUse}` : ''
280
+ const entry = `/${command.name} - ${command.description}${whenSuffix}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
275
281
  if (used + entry.length + 1 > budget) {
276
282
  omitted++
277
283
  continue // a shorter later entry may still fit the remaining budget
@@ -303,6 +309,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
303
309
  let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
304
310
  /** The session model to restore after a command's `model:` override drove its run. */
305
311
  let pendingModelRestore: ModelLike | undefined
312
+ /** The thinking level to restore after a command's `effort:` override drove its run. */
313
+ let pendingEffortRestore: string | undefined
306
314
 
307
315
  // Claude's contract is "the grant clears when you send your next message", and
308
316
  // pi's turn_end fires after every assistant step: restoring there stripped a
@@ -323,6 +331,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
323
331
  // command's override model.
324
332
  void pi.setModel(restore).catch(() => {})
325
333
  }
334
+ if (pendingEffortRestore) {
335
+ const level = pendingEffortRestore as Parameters<typeof pi.setThinkingLevel>[0]
336
+ pendingEffortRestore = undefined
337
+ pi.setThinkingLevel(level)
338
+ }
326
339
  if (pendingRestore) {
327
340
  pi.setActiveTools(pendingRestore)
328
341
  pendingRestore = undefined
@@ -404,6 +417,19 @@ export default function commandsExtension(pi: ExtensionAPI) {
404
417
  }
405
418
  }
406
419
 
420
+ /** Claude's `effort:` frontmatter overrides the thinking level for this run only, then
421
+ * the session level resumes; restore happens on agent_settled like the model restore.
422
+ * Applied before sendUserMessage so the run it drives happens at the new level. Only the
423
+ * first override in a turn records the restore target, so a second command restores to
424
+ * the original session level rather than the first command's override (as pendingModelRestore). */
425
+ function applyEffortOverride(parsed: ParsedCommand, varCtx: VarContext): void {
426
+ const target = parsed.effort
427
+ if (target && varCtx.thinkingLevel && target !== varCtx.thinkingLevel) {
428
+ pendingEffortRestore = pendingEffortRestore ?? varCtx.thinkingLevel
429
+ pi.setThinkingLevel(target as Parameters<typeof pi.setThinkingLevel>[0])
430
+ }
431
+ }
432
+
407
433
  async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: CommandPlugin): Promise<void> {
408
434
  const varCtx = ctx as unknown as VarContext
409
435
  const vars = commandVars(ctx, filePath, plugin)
@@ -435,6 +461,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
435
461
  applyAllowedTools(parsed, vars)
436
462
  applyDisallowedTools(parsed)
437
463
  await applyModelOverride(parsed, varCtx)
464
+ applyEffortOverride(parsed, varCtx)
438
465
  pi.sendUserMessage(expanded)
439
466
  }
440
467
 
@@ -463,7 +490,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
463
490
  discovered.set(command.name, command)
464
491
  // A user-only command stays off the tool description; it is still in the
465
492
  // map so a model attempt gets the explicit refusal, not "unknown command".
466
- if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint })
493
+ if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint, whenToUse: parsed.whenToUse })
494
+ // user-invocable:false is the inverse of disable-model-invocation: the command is
495
+ // hidden from the user slash-command surface but stays in `discovered` and the tool
496
+ // description above, so the model can still run it through the slash_command tool.
497
+ if (!parsed.userInvocable) continue
467
498
  // pi has no unregister, so a command already registered this process keeps its
468
499
  // original file binding; re-registering would only add a numbered duplicate.
469
500
  if (registered.has(command.name)) continue