pi-code 1.0.9 → 1.0.11
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 +2 -1
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +196 -38
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/hooks.ts +92 -11
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/plugins.ts +5 -3
- package/extensions/mcp.ts +146 -24
- package/extensions/memory.ts +86 -2
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +10 -2
- package/extensions/session-title.ts +126 -0
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +94 -1
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +2 -1
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ 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
26
|
import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
|
|
26
27
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
@@ -210,7 +211,7 @@ export function pendingScopedRuleCount(): number {
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
213
|
-
const globalRulesDir = path.join(os.homedir(), '
|
|
214
|
+
const globalRulesDir = path.join(claudeConfigDir(os.homedir()), 'rules')
|
|
214
215
|
let globalRules: RuleSet = EMPTY_RULES
|
|
215
216
|
let projectRules: RuleSet = EMPTY_RULES
|
|
216
217
|
// The base a scoped-rule pointer is written against, so the model's read resolves.
|
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
|
|
@@ -10,14 +10,23 @@
|
|
|
10
10
|
* imported content plus the approval-gated CLAUDE.local.md body. The base
|
|
11
11
|
* files pi already injected are never re-appended.
|
|
12
12
|
*
|
|
13
|
+
* It also loads the Claude Code memory locations pi's own loader misses, each
|
|
14
|
+
* through the same exclude/strip/announce/import pipeline: the user-scope
|
|
15
|
+
* ~/.claude/CLAUDE.md (the user's own file, no approval gate, @imports at
|
|
16
|
+
* user-config roots), the project-scope alternate ./.claude/CLAUDE.md (nearest at
|
|
17
|
+
* or above cwd, approval-gated, deduped against pi's native blocks, @imports at
|
|
18
|
+
* project roots), and the enterprise managed CLAUDE.md file deployed beside
|
|
19
|
+
* managed-settings.json.
|
|
20
|
+
*
|
|
13
21
|
* It also rewrites the context blocks pi assembled, by exact-substring
|
|
14
22
|
* replacement of the wrapper reconstructed from each file's path+content (a
|
|
15
|
-
* wrapper that is not found is skipped, never guessed at):
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* wrapper that is not found is skipped, never guessed at): the managed claudeMd
|
|
24
|
+
* (the managed CLAUDE.md file first, then the managed-settings `claudeMd` key)
|
|
25
|
+
* and the user CLAUDE.md are prepended at the top of <project_context> in Claude's
|
|
26
|
+
* order (managed, user, then pi's native project blocks; managed is managed-source
|
|
27
|
+
* only and never excludable), files matching the merged `claudeMdExcludes` globs
|
|
28
|
+
* are removed along with their imports, and block-level HTML comments are stripped
|
|
29
|
+
* from every surviving body (see internal/strip-comments).
|
|
21
30
|
*
|
|
22
31
|
* Security: context files can come from an untrusted project, so imports are
|
|
23
32
|
* confined (after resolving symlinks) to the working directory plus its
|
|
@@ -43,11 +52,13 @@
|
|
|
43
52
|
*
|
|
44
53
|
* Loads are also announced on the shared instruction-events bus for the
|
|
45
54
|
* InstructionsLoaded hook: `include` for each resolved @import, `session_start`
|
|
46
|
-
* for the native context files that survived claudeMdExcludes plus
|
|
47
|
-
* CLAUDE.
|
|
48
|
-
*
|
|
49
|
-
* exclusion
|
|
50
|
-
*
|
|
55
|
+
* for the native context files that survived claudeMdExcludes plus CLAUDE.local.md,
|
|
56
|
+
* the user (User) and project ./.claude/CLAUDE.md (Project), the managed file
|
|
57
|
+
* (Managed) and additional-dir files, once per file per session. This extension
|
|
58
|
+
* owns exclusion, so it owns the announcements too: a file the exclusion removed
|
|
59
|
+
* never announces, and the hooks extension only consumes the bus (emit is
|
|
60
|
+
* synchronous, so extension order does not matter). The managed-settings `claudeMd`
|
|
61
|
+
* key is not a file pi loaded, so like today it is inserted but not announced.
|
|
51
62
|
*
|
|
52
63
|
* Docs: https://code.claude.com/docs/en/memory.md (imports)
|
|
53
64
|
*/
|
|
@@ -58,8 +69,9 @@ import * as os from 'node:os'
|
|
|
58
69
|
import * as path from 'node:path'
|
|
59
70
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
60
71
|
|
|
72
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
61
73
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
62
|
-
import { readManagedSettings } from './internal/managed-settings.js'
|
|
74
|
+
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
63
75
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
64
76
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
65
77
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
@@ -223,7 +235,7 @@ export function collectImports(content: string, fromDir: string, home: string, a
|
|
|
223
235
|
* would let it read them into the system prompt.
|
|
224
236
|
*/
|
|
225
237
|
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
226
|
-
const userRoots = realRoots([
|
|
238
|
+
const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
|
|
227
239
|
const [real] = realRoots([importer])
|
|
228
240
|
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
229
241
|
if (fromUserConfig) return realRoots([cwd, ...userRoots])
|
|
@@ -287,9 +299,34 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
|
|
|
287
299
|
return files
|
|
288
300
|
}
|
|
289
301
|
|
|
290
|
-
/** Path label given to the managed claudeMd block; not a file pi loaded. */
|
|
302
|
+
/** Path label given to the managed claudeMd settings-key block; not a file pi loaded. */
|
|
291
303
|
export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
|
|
292
304
|
|
|
305
|
+
let managedClaudeMdPathOverride: string | undefined
|
|
306
|
+
|
|
307
|
+
/** Test seam mirroring setManagedSettingsPath: point the managed CLAUDE.md file
|
|
308
|
+
* readers consult at a writable directory. */
|
|
309
|
+
export function setManagedClaudeMdPath(file?: string): void {
|
|
310
|
+
managedClaudeMdPathOverride = file
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The managed CLAUDE.md file path: alongside managed-settings.json, in the same OS
|
|
314
|
+
* directory IT deploys the enterprise policy to (managed-settings.ts owns that
|
|
315
|
+
* directory per platform). Organizations ship a CLAUDE.md there to load before user
|
|
316
|
+
* and project context. Overridable for tests. */
|
|
317
|
+
export function managedClaudeMdPath(): string {
|
|
318
|
+
return managedClaudeMdPathOverride ?? path.join(path.dirname(managedSettingsPath()), 'CLAUDE.md')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The managed CLAUDE.md file body, or '' when absent or unreadable. */
|
|
322
|
+
export function readManagedClaudeMdFile(): string {
|
|
323
|
+
try {
|
|
324
|
+
return fs.readFileSync(managedClaudeMdPath(), 'utf-8')
|
|
325
|
+
} catch {
|
|
326
|
+
return ''
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
293
330
|
/** pi's exact per-file wrapper inside <project_context>, reconstructed from
|
|
294
331
|
* path+content for exact-substring rewriting. tests/context-imports.test.ts pins
|
|
295
332
|
* this format against pi's own source so drift fails loudly instead of silently
|
|
@@ -318,10 +355,12 @@ function replaceBlock(prompt: string, wrapper: string, replacement: string): str
|
|
|
318
355
|
return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
|
|
319
356
|
}
|
|
320
357
|
|
|
321
|
-
/** Insert
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
|
|
358
|
+
/** Insert a block at the top of <project_context>, before the files pi loaded;
|
|
359
|
+
* when pi assembled no context block, add one in pi's shape. Used for the blocks
|
|
360
|
+
* Claude loads ahead of pi's native project context: managed claudeMd (file then
|
|
361
|
+
* key) and the user CLAUDE.md. Each call prepends, so the last block inserted ends
|
|
362
|
+
* up highest, which is how the managed/user/native order is built (see caller). */
|
|
363
|
+
function withTopBlock(prompt: string, block: string): string {
|
|
325
364
|
for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
|
|
326
365
|
const at = prompt.indexOf(anchor)
|
|
327
366
|
if (at === -1) continue
|
|
@@ -336,7 +375,7 @@ function withManagedBlock(prompt: string, block: string): string {
|
|
|
336
375
|
* settings.local.json (nearest at or above cwd) only when the project is
|
|
337
376
|
* approved. Managed settings are read separately by the caller. */
|
|
338
377
|
export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
339
|
-
const files = [path.join(home, '
|
|
378
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
340
379
|
if (!approved) return files
|
|
341
380
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
342
381
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -454,6 +493,16 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
|
|
|
454
493
|
return imported
|
|
455
494
|
}
|
|
456
495
|
|
|
496
|
+
/** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
|
|
497
|
+
* announced as it is added (only when non-empty, matching what reaches the prompt).
|
|
498
|
+
* Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
|
|
499
|
+
* the former natively, so this fills the alternate location as an extra project block. */
|
|
500
|
+
function projectContextAddition(kept: { path: string; content: string } | undefined, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
501
|
+
if (kept === undefined || kept.content.trim().length === 0) return ''
|
|
502
|
+
announce({ file_path: kept.path, memory_type: memoryTypeForPath(kept.path, home, projectRoot), load_reason: 'session_start' })
|
|
503
|
+
return `\n\n${instructionsBlock(kept.path, kept.content.trim())}`
|
|
504
|
+
}
|
|
505
|
+
|
|
457
506
|
/** The CLAUDE.local.md bodies appended after the native context, announced as they
|
|
458
507
|
* are added (only the non-empty ones, matching what actually reaches the prompt). */
|
|
459
508
|
function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
@@ -492,8 +541,74 @@ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home:
|
|
|
492
541
|
return `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
493
542
|
}
|
|
494
543
|
|
|
544
|
+
/** Prepend the managed and user memory blocks Claude loads ahead of pi's native project
|
|
545
|
+
* context, top to bottom: managed file, managed key, then the user CLAUDE.md. withTopBlock
|
|
546
|
+
* prepends, so they are inserted bottom-up (user, then managed key, then managed file just
|
|
547
|
+
* below) to land in that order. keptUser was already exclude-checked and comment-stripped by
|
|
548
|
+
* the caller like every other file; the managed claudeMd is never excludable and comes from
|
|
549
|
+
* two managed-only surfaces, the settings key (ignored in user/project settings) and the file
|
|
550
|
+
* IT deploys beside managed-settings.json, re-read every turn so a policy change applies
|
|
551
|
+
* immediately (only its announce is deduped per session). Returns the grown prompt, whether
|
|
552
|
+
* anything was added, and the managed file body (reused for the import memo key). */
|
|
553
|
+
function prependMemoryBlocks(prompt: string, changed: boolean, keptUser: { path: string; content: string } | undefined, managed: Record<string, unknown>, announce: (event: InstructionLoadEvent) => void): { prompt: string; changed: boolean; managedFile: string } {
|
|
554
|
+
if (keptUser !== undefined && keptUser.content.trim().length > 0) {
|
|
555
|
+
prompt = withTopBlock(prompt, instructionsBlock(keptUser.path, keptUser.content.trim()))
|
|
556
|
+
changed = true
|
|
557
|
+
announce({ file_path: keptUser.path, memory_type: 'User', load_reason: 'session_start' })
|
|
558
|
+
}
|
|
559
|
+
const managedKey = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
|
|
560
|
+
if (managedKey.length > 0) {
|
|
561
|
+
prompt = withTopBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedKey))
|
|
562
|
+
changed = true
|
|
563
|
+
}
|
|
564
|
+
const managedFile = stripBlockComments(readManagedClaudeMdFile()).trim()
|
|
565
|
+
if (managedFile.length > 0) {
|
|
566
|
+
prompt = withTopBlock(prompt, instructionsBlock(managedClaudeMdPath(), managedFile))
|
|
567
|
+
changed = true
|
|
568
|
+
announce({ file_path: managedClaudeMdPath(), memory_type: 'Managed', load_reason: 'session_start' })
|
|
569
|
+
}
|
|
570
|
+
return { prompt, changed, managedFile }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Everything the import expansion depends on, hashed to a memo key: a turn whose inputs
|
|
574
|
+
* match a prior key and whose recorded mtimes are unchanged reuses the previous expansion
|
|
575
|
+
* outright. The native/local paths, the user/project-.claude additions, and the managed file
|
|
576
|
+
* body seed the "seen" set, so a change in which of them exist (even an excluded one that
|
|
577
|
+
* never reaches contextFiles) changes the key; the managed file is re-read every turn, so a
|
|
578
|
+
* change in its content re-expands and keeps the seed self-consistent. */
|
|
579
|
+
function buildImportMemoKey(input: {
|
|
580
|
+
cwd: string
|
|
581
|
+
home: string
|
|
582
|
+
projectApproved: boolean
|
|
583
|
+
addDirsRaw: string
|
|
584
|
+
excludeGlobs: string[]
|
|
585
|
+
native: Array<{ path: string; content: string }>
|
|
586
|
+
localContexts: Array<{ path: string; content: string }>
|
|
587
|
+
userContext: { path: string; content: string } | undefined
|
|
588
|
+
projectDotClaude: { path: string; content: string } | undefined
|
|
589
|
+
managedFile: string
|
|
590
|
+
contextFiles: Array<{ path: string; content: string }>
|
|
591
|
+
}): string {
|
|
592
|
+
const keyHash = createHash('sha256')
|
|
593
|
+
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
594
|
+
for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
|
|
595
|
+
if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
|
|
596
|
+
if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
|
|
597
|
+
keyHash.update(`${input.managedFile}\0`)
|
|
598
|
+
for (const file of input.contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
|
|
599
|
+
return keyHash.digest('hex')
|
|
600
|
+
}
|
|
601
|
+
|
|
495
602
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
496
603
|
let localContexts: Array<{ path: string; content: string }> = []
|
|
604
|
+
// ~/.claude/CLAUDE.md, Claude's user-scope memory (all projects). The user's own
|
|
605
|
+
// file, so it needs no project approval; read once at session start like the
|
|
606
|
+
// locals, so a mid-session body edit applies next session, matching Claude.
|
|
607
|
+
let userContext: { path: string; content: string } | undefined
|
|
608
|
+
// The project-scope alternate location ./.claude/CLAUDE.md. pi loads ./CLAUDE.md
|
|
609
|
+
// natively but not this one; repo-controlled, so it is approval-gated like the
|
|
610
|
+
// locals and read once at session start.
|
|
611
|
+
let projectDotClaude: { path: string; content: string } | undefined
|
|
497
612
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
498
613
|
// start with the silent check, so no prompt fires mid-flight.
|
|
499
614
|
let projectApproved = false
|
|
@@ -556,7 +671,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
556
671
|
}
|
|
557
672
|
// Seed with every loaded context file path, excluded ones included, so pi's own
|
|
558
673
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
559
|
-
|
|
674
|
+
// The user, project-.claude and managed-file additions join the seed too, so a
|
|
675
|
+
// context file's @import cannot pull any of them in a second time.
|
|
676
|
+
const ownPaths = [...native, ...localContexts].map((file) => file.path)
|
|
677
|
+
if (userContext !== undefined) ownPaths.push(userContext.path)
|
|
678
|
+
if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
|
|
679
|
+
ownPaths.push(managedClaudeMdPath())
|
|
680
|
+
const seenSet = new Set(realRoots(ownPaths))
|
|
560
681
|
|
|
561
682
|
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
562
683
|
// before import expansion so an @import cannot pull one in twice, and they get
|
|
@@ -598,14 +719,29 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
598
719
|
announced.clear()
|
|
599
720
|
envCache = undefined
|
|
600
721
|
importMemo = undefined
|
|
722
|
+
localContexts = []
|
|
723
|
+
userContext = undefined
|
|
724
|
+
projectDotClaude = undefined
|
|
725
|
+
|
|
726
|
+
// ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
|
|
727
|
+
// project approval is required; a missing file simply leaves it unset.
|
|
728
|
+
try {
|
|
729
|
+
const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
|
|
730
|
+
userContext = { path: userClaudeMd, content: fs.readFileSync(userClaudeMd, 'utf-8') }
|
|
731
|
+
} catch {
|
|
732
|
+
// no user CLAUDE.md
|
|
733
|
+
}
|
|
734
|
+
|
|
601
735
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
602
736
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
603
737
|
// Claude loads local context from the whole hierarchy above the working
|
|
604
738
|
// directory, ordered root down to cwd; the walk is bounded at the repository
|
|
605
|
-
// root like every other project-config search here.
|
|
606
|
-
|
|
739
|
+
// root like every other project-config search here. The project-scope alternate
|
|
740
|
+
// ./.claude/CLAUDE.md (nearest at or above cwd) is repo-controlled too, so both
|
|
741
|
+
// ride the one approval decision.
|
|
607
742
|
const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
|
|
608
|
-
|
|
743
|
+
const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
744
|
+
if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
|
|
609
745
|
for (const candidate of candidates) {
|
|
610
746
|
try {
|
|
611
747
|
localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
|
|
@@ -613,6 +749,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
613
749
|
// unreadable: treat as absent
|
|
614
750
|
}
|
|
615
751
|
}
|
|
752
|
+
if (dotClaudeMd !== null) {
|
|
753
|
+
try {
|
|
754
|
+
projectDotClaude = { path: dotClaudeMd, content: fs.readFileSync(dotClaudeMd, 'utf-8') }
|
|
755
|
+
} catch {
|
|
756
|
+
// unreadable: treat as absent
|
|
757
|
+
}
|
|
758
|
+
}
|
|
616
759
|
}
|
|
617
760
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
618
761
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
@@ -643,30 +786,45 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
643
786
|
announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
|
|
644
787
|
}
|
|
645
788
|
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
789
|
+
// The user CLAUDE.md is the user's own file (no approval gate) but respects
|
|
790
|
+
// claudeMdExcludes and comment-stripping like every other file. It is kept here so it
|
|
791
|
+
// both gets its own block (via prependMemoryBlocks) and joins the import-expansion set
|
|
792
|
+
// below, so its @imports resolve.
|
|
793
|
+
const keptUser = userContext !== undefined && !excluded(userContext.path) ? { path: userContext.path, content: stripBlockComments(userContext.content) } : undefined
|
|
794
|
+
|
|
795
|
+
// Prepend the managed and user memory blocks (managed file, managed key, user), the
|
|
796
|
+
// blocks Claude loads ahead of pi's native project context. managedFile comes back
|
|
797
|
+
// because it also seeds the import memo key below.
|
|
798
|
+
const top = prependMemoryBlocks(prompt, changed, keptUser, managed, announce)
|
|
799
|
+
prompt = top.prompt
|
|
800
|
+
changed = top.changed
|
|
801
|
+
const managedFile = top.managedFile
|
|
654
802
|
|
|
655
803
|
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
656
|
-
|
|
804
|
+
|
|
805
|
+
// ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
|
|
806
|
+
// loads it too there is no double block, then exclude-checked and comment-stripped
|
|
807
|
+
// like the rest. Its @imports resolve at project roots (rootsForImporter).
|
|
808
|
+
const nativeReal = new Set(realRoots(native.map((file) => file.path)))
|
|
809
|
+
const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
|
|
810
|
+
const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
|
|
811
|
+
|
|
812
|
+
// The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
|
|
813
|
+
// @imports resolve (each at roots scoped to it, via rootsForImporter); their own
|
|
814
|
+
// bodies are placed separately, so expansion only surfaces what they import.
|
|
815
|
+
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
657
816
|
|
|
658
817
|
// Everything the expansion depends on, hashed: a turn whose inputs match the memo
|
|
659
818
|
// and whose recorded mtimes are unchanged reuses the previous expansion outright.
|
|
660
819
|
const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
|
|
661
|
-
const
|
|
662
|
-
keyHash.update(`${cwd}\0${home}\0${projectApproved}\0${addDirsRaw}\0${excludeGlobs.join(',')}\0`)
|
|
663
|
-
for (const file of [...native, ...localContexts]) keyHash.update(`${file.path}\0`)
|
|
664
|
-
for (const file of contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
|
|
665
|
-
const memoKey = keyHash.digest('hex')
|
|
820
|
+
const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
|
|
666
821
|
|
|
667
822
|
const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
|
|
668
823
|
|
|
669
|
-
|
|
824
|
+
// Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
|
|
825
|
+
// additions, ahead of the CLAUDE.local.md bodies.
|
|
826
|
+
let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
|
|
827
|
+
addition += localContextAddition(keptLocals, announce)
|
|
670
828
|
addition += additionalDirsAddition(extras, announce)
|
|
671
829
|
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
672
830
|
if (!changed && addition.length === 0) return
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Usage Command
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `/context` reports how much of the model's context window the
|
|
5
|
+
* current session occupies. This registers a `context` command that reads pi's
|
|
6
|
+
* live ContextUsage and renders a concise breakdown through notify. pi exposes
|
|
7
|
+
* three fields (tokens, contextWindow, percent); the used/window/free lines and
|
|
8
|
+
* the percentage are built from exactly those, with the model's window as a
|
|
9
|
+
* fallback when the usage snapshot does not carry one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ContextUsage, ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
|
|
14
|
+
const fmt = (n: number): string => n.toLocaleString('en-US')
|
|
15
|
+
|
|
16
|
+
/** The breakdown text for a usage snapshot, or a friendly line when there is none.
|
|
17
|
+
* `tokens` is null right after a compaction (before the next response recounts), so
|
|
18
|
+
* that case reports a recalculating state instead of a bogus zero. */
|
|
19
|
+
export function formatContextUsage(usage: ContextUsage | undefined, modelWindow?: number): string {
|
|
20
|
+
if (!usage) {
|
|
21
|
+
return 'Context usage is not available yet. It appears once the model has responded (and resets right after a compaction).'
|
|
22
|
+
}
|
|
23
|
+
const window = usage.contextWindow || modelWindow || 0
|
|
24
|
+
if (usage.tokens === null) {
|
|
25
|
+
const tail = window > 0 ? ` Window: ${fmt(window)} tokens.` : ''
|
|
26
|
+
return `Context usage: recalculating the token count (e.g. right after a compaction).${tail}`
|
|
27
|
+
}
|
|
28
|
+
const tokens = usage.tokens
|
|
29
|
+
const percent = usage.percent ?? (window > 0 ? (tokens / window) * 100 : null)
|
|
30
|
+
const percentSuffix = percent === null ? '' : ` (${percent.toFixed(1)}%)`
|
|
31
|
+
const lines = ['Context usage', ` Used: ${fmt(tokens)} tokens${percentSuffix}`]
|
|
32
|
+
if (window > 0) {
|
|
33
|
+
lines.push(` Window: ${fmt(window)} tokens`, ` Free: ${fmt(Math.max(window - tokens, 0))} tokens`)
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
39
|
+
pi.registerCommand('context', {
|
|
40
|
+
description: 'Show how much of the model context window this session is using',
|
|
41
|
+
handler: async (_args, ctx) => {
|
|
42
|
+
ctx.ui.notify(formatContextUsage(ctx.getContextUsage(), ctx.model?.contextWindow), 'info')
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.json `env` injection.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code lets any settings scope carry an `env` object whose keys are exported
|
|
5
|
+
* into the session's environment. This extension applies that chain to process.env:
|
|
6
|
+
* managed-settings.json (enterprise policy), ~/.claude/settings.json (user), and the
|
|
7
|
+
* project's .claude/settings.json plus settings.local.json.
|
|
8
|
+
*
|
|
9
|
+
* Two things run this. The factory body applies managed + user immediately (as pi
|
|
10
|
+
* loads extensions), so those variables are present before the first turn; a session
|
|
11
|
+
* that never approves a project still gets them. session_start refreshes and, only
|
|
12
|
+
* when the project is approved, folds in the project scope. The project scope stays
|
|
13
|
+
* approval-gated on purpose: a checked-out repository's env can redirect providers
|
|
14
|
+
* (ANTHROPIC_BASE_URL and friends), so an untrusted repo must not reach process.env.
|
|
15
|
+
*
|
|
16
|
+
* Precedence is per key, managed > user > project, matching Claude's merge: a scope
|
|
17
|
+
* only supplies keys it names and never wipes another scope's keys. Values must be
|
|
18
|
+
* strings; a number or boolean is coerced via String, anything else is skipped.
|
|
19
|
+
*
|
|
20
|
+
* A variable already present in the real environment that this module did not set is
|
|
21
|
+
* left untouched for the user and project scopes: a shell `export` outranks them. The
|
|
22
|
+
* managed scope is the exception: an org policy env must win over an ambient export, so
|
|
23
|
+
* a managed key overwrites a preexisting shell value. The keys this module sets are
|
|
24
|
+
* recorded so a later refresh can update them without clobbering unrelated env, and a
|
|
25
|
+
* key an earlier apply set that the current merge no longer defines is unset (deleted
|
|
26
|
+
* from process.env), so an approved project's env cannot leak into a later session or
|
|
27
|
+
* project that does not define it. A managed overwrite of a shell var is owned like any
|
|
28
|
+
* other set key; its original shell value cannot be restored, so on unset it is deleted.
|
|
29
|
+
*
|
|
30
|
+
* Docs: https://code.claude.com/docs/en/settings.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from 'node:fs'
|
|
34
|
+
import * as os from 'node:os'
|
|
35
|
+
import * as path from 'node:path'
|
|
36
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
37
|
+
|
|
38
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
39
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
40
|
+
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
41
|
+
import { findNearestFile } from './internal/project-root.js'
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
48
|
+
* as-is, a number or boolean becomes its String() form, and anything else (object,
|
|
49
|
+
* array, null) is dropped, matching Claude which only injects string-valued env. */
|
|
50
|
+
export function envFromSettings(settings: unknown): Record<string, string> {
|
|
51
|
+
const out: Record<string, string> = {}
|
|
52
|
+
if (!isRecord(settings) || !isRecord(settings.env)) return out
|
|
53
|
+
for (const [key, value] of Object.entries(settings.env)) {
|
|
54
|
+
if (typeof value === 'string') out[key] = value
|
|
55
|
+
else if (typeof value === 'number' || typeof value === 'boolean') out[key] = String(value)
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Merge the three env scopes with Claude's per-key precedence managed > user >
|
|
61
|
+
* project: lower scopes are laid down first and higher ones overlay, so each key
|
|
62
|
+
* takes its highest-precedence value and no scope wipes another's keys. */
|
|
63
|
+
export function mergeEnvScopes(managed: Record<string, string>, user: Record<string, string>, project: Record<string, string>): Record<string, string> {
|
|
64
|
+
return { ...project, ...user, ...managed }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Assign the merged env into `env`, recording each key set into `owned`. A key already
|
|
68
|
+
* present that this module did not set (a shell export) is left untouched for user and
|
|
69
|
+
* project keys; a `managedKeys` entry overwrites it (an org policy outranks the shell).
|
|
70
|
+
* A key this module set before is updated. A previously-owned key that the current
|
|
71
|
+
* `merged` no longer defines is unset (deleted from `env` and `owned`), so an approved
|
|
72
|
+
* project's env cannot leak into a later apply that does not define it. */
|
|
73
|
+
export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Set<string>, managedKeys: ReadonlySet<string> = new Set()): void {
|
|
74
|
+
// Unset any key an earlier apply set that the current merge dropped. Iterate a copy
|
|
75
|
+
// since `owned` is mutated. A shell export this module never owned is left in place.
|
|
76
|
+
for (const key of Array.from(owned)) {
|
|
77
|
+
if (!(key in merged)) {
|
|
78
|
+
delete env[key]
|
|
79
|
+
owned.delete(key)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
83
|
+
// A shell export outranks user/project (skip), but a managed key outranks even the
|
|
84
|
+
// shell. Once owned, updates always apply. A managed overwrite is owned like any
|
|
85
|
+
// set key: its shell value is gone and cannot be restored, so on unset it is deleted.
|
|
86
|
+
if (key in env && !owned.has(key) && !managedKeys.has(key)) continue
|
|
87
|
+
env[key] = value
|
|
88
|
+
owned.add(key)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSettingsFile(file: string): Record<string, unknown> {
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
95
|
+
if (isRecord(parsed)) return parsed
|
|
96
|
+
} catch {
|
|
97
|
+
// missing or invalid file: no env from this scope
|
|
98
|
+
}
|
|
99
|
+
return {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The user scope's env: ~/.claude/settings.json (relocated by CLAUDE_CONFIG_DIR). */
|
|
103
|
+
function userEnv(home: string): Record<string, string> {
|
|
104
|
+
return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The project scope's env: .claude/settings.json with settings.local.json overlaid,
|
|
108
|
+
* each the nearest of its name at or above cwd (matching the hooks settings chain). */
|
|
109
|
+
function projectEnv(cwd: string): Record<string, string> {
|
|
110
|
+
const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
|
|
111
|
+
const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
|
|
112
|
+
return { ...base, ...local }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
116
|
+
const owned = new Set<string>()
|
|
117
|
+
|
|
118
|
+
const apply = (home: string, project: Record<string, string>): void => {
|
|
119
|
+
const managed = envFromSettings(readManagedSettings())
|
|
120
|
+
applyEnvSettings(mergeEnvScopes(managed, userEnv(home), project), process.env, owned, new Set(Object.keys(managed)))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Factory time: managed + user only. Approval needs the session ctx, so the project
|
|
124
|
+
// scope waits for session_start; running here means these vars land before the first turn.
|
|
125
|
+
apply(os.homedir(), {})
|
|
126
|
+
|
|
127
|
+
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
128
|
+
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
|
|
129
|
+
})
|
|
130
|
+
}
|