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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(), '
|
|
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
|
|
222
|
-
//
|
|
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.
|
|
240
|
-
|
|
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) => ({
|
|
244
|
-
...projectRules.scoped.map((rule) => ({
|
|
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
|
-
|
|
287
|
-
if (!
|
|
288
|
-
|
|
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.
|
|
292
|
-
//
|
|
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
|
})
|
package/extensions/commands.ts
CHANGED
|
@@ -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, '
|
|
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, '
|
|
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
|
-
|
|
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
|