pi-code 1.0.36 → 1.0.37

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.
@@ -5,8 +5,8 @@
5
5
  * handing `.claude/commands` to pi's prompt-template loader. Owning registration
6
6
  * is what makes the rest of Claude's command contract reachable: `$ARGUMENTS` and
7
7
  * positional substitution, `` !`cmd` `` bash output, `@file` inlining, subdirectory
8
- * commands (registered as `/frontend:build`; current Claude docs name a command by
9
- * file name alone, so the qualified form is a pi-code divergence), and the
8
+ * commands (named by file name alone, as Claude documents; subdirectories only
9
+ * organize the files), and the
10
10
  * `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
11
11
  * session model for the command's run via `pi.setModel`, restored on agent_end).
12
12
  * `shell: powershell` runs a command's injected spans through PowerShell when a
@@ -47,7 +47,7 @@ import { Type } from 'typebox'
47
47
  import { matchesBashRules } from './internal/bash-rules.js'
48
48
  import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
49
49
  import { claudeConfigDir } from './internal/config-dir.js'
50
- import { readManagedSettings } from './internal/managed-settings.js'
50
+ import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
51
51
  import { capForContext } from './internal/output-guard.js'
52
52
  import { matchesPathRules } from './internal/path-rules.js'
53
53
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
@@ -115,15 +115,17 @@ function isDirectory(target: string): boolean {
115
115
  }
116
116
  }
117
117
 
118
- /** Existing `.claude/commands` directories, user first then project. The project
119
- * directory is the nearest at or above cwd (bounded at the repository root, matching
120
- * the approval walk) and is included only for approved projects. */
118
+ /** Existing `.claude/commands` directories in Claude's precedence order (later
119
+ * directories win in collectCommands): project first, then personal, then the
120
+ * enterprise directory beside the managed settings file, per "enterprise
121
+ * overrides personal, and personal overrides project". The project directories
122
+ * are included only for approved projects. */
121
123
  export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
122
- const candidates = [path.join(claudeConfigDir(home), 'commands')]
124
+ const candidates: string[] = []
123
125
  // Claude scans every .claude/commands between cwd and the repository root, the
124
- // nearest winning a name clash: collectCommands lets later directories win, so
125
- // the project list goes root-first with the nearest last.
126
+ // nearest winning an intra-project name clash: root-first with the nearest last.
126
127
  if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'commands')).reverse())
128
+ candidates.push(path.join(claudeConfigDir(home), 'commands'), path.join(path.dirname(managedSettingsFile()), '.claude', 'commands'))
127
129
  const dirs: string[] = []
128
130
  for (const dir of candidates) {
129
131
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
@@ -270,23 +272,22 @@ export function slashCommandBudget(contextWindow: number | undefined, env: Recor
270
272
  /** The slash_command tool description: usage framing plus the budgeted command
271
273
  * list, each entry `/name - description (argument-hint)`. */
272
274
  export function slashCommandToolDescription(commands: SlashCommandEntry[], budget: number): string {
273
- const lines: string[] = []
274
- let used = 0
275
- let omitted = 0
276
- for (const command of commands) {
275
+ // Claude: "The listing always contains every skill name"; the budget shortens
276
+ // descriptions (later entries lose theirs first), never drops a name, so an
277
+ // omitted-but-invocable command can no longer contradict the listing.
278
+ const lines = commands.map((command) => `/${command.name}`)
279
+ let used = lines.reduce((total, line) => total + line.length + 1, 0)
280
+ for (const [index, command] of commands.entries()) {
277
281
  const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
278
282
  // when_to_use is model-facing trigger text, appended after the description and before
279
283
  // the argument hint; it shares the per-entry cap and never reaches the user surface.
280
284
  const whenSuffix = command.whenToUse ? ` ${command.whenToUse}` : ''
281
285
  const entry = `/${command.name} - ${command.description}${whenSuffix}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
282
- if (used + entry.length + 1 > budget) {
283
- omitted++
284
- continue // a shorter later entry may still fit the remaining budget
285
- }
286
- used += entry.length + 1
287
- lines.push(entry)
286
+ const growth = entry.length - lines[index].length
287
+ if (used + growth > budget) continue
288
+ used += growth
289
+ lines[index] = entry
288
290
  }
289
- if (omitted > 0) lines.push(`(${omitted} more ${omitted === 1 ? 'command was' : 'commands were'} omitted: raise SLASH_COMMAND_TOOL_CHAR_BUDGET to list them)`)
290
291
  return ["Execute a custom slash command on the user's behalf. The command expands to instructions for you to follow in this conversation.", '', 'Available commands:', ...lines].join('\n')
291
292
  }
292
293
 
@@ -394,9 +394,11 @@ export function substituteVars(text: string, vars: Record<string, string | undef
394
394
  return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
395
395
  }
396
396
 
397
- /** `a/b/c.md` becomes Claude's `a:b:c`. */
397
+ /** Claude: "You invoke a command file by its file name"; subdirectories organize
398
+ * files without namespacing, so `a/b/c.md` is `/c`. A same-name file in another
399
+ * subdirectory takes the name over (scan order decides), as with Claude. */
398
400
  export function commandNameFor(relativePath: string): string {
399
- return relativePath.replace(/\.md$/, '').split(path.sep).join(':')
401
+ return path.basename(relativePath, '.md')
400
402
  }
401
403
 
402
404
  /** Every `*.md` under a commands directory, including nested ones. */
@@ -7,20 +7,38 @@
7
7
 
8
8
  import * as fs from 'node:fs'
9
9
 
10
+ // Captured at module load: the poll must run on real time even under a test's
11
+ // fake timers (the stat watcher it replaced lived in libuv and was immune too);
12
+ // otherwise fake-timer advances spin the poll and freeze real detection.
13
+ const realSetInterval = globalThis.setInterval
14
+ const realClearInterval = globalThis.clearInterval
15
+
16
+ /** One file's content, or undefined when absent or unreadable. */
17
+ function snapshot(file: string): string | undefined {
18
+ try {
19
+ return fs.readFileSync(file, 'utf-8')
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
10
25
  /** Watch the given settings files, calling `reload` when any of them changes.
11
- * Returns a dispose function. The poll interval is env-tunable for tests. */
26
+ * Returns a dispose function. The poll compares content, not stats: a same-size
27
+ * rewrite within one timestamp tick is invisible to an mtime comparison on a
28
+ * coarse-granularity filesystem, which made detection flaky. Settings files are
29
+ * small, so re-reading them on the poll is negligible. The interval is
30
+ * env-tunable for tests. */
12
31
  export function watchSettingsFiles(files: string[], reload: () => void): () => void {
13
32
  const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
14
- const listeners: Array<[string, (curr: fs.Stats, prev: fs.Stats) => void]> = []
15
- for (const file of files) {
16
- const listener = (curr: fs.Stats, prev: fs.Stats): void => {
17
- if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) reload()
33
+ let last = files.map(snapshot)
34
+ const timer = realSetInterval(() => {
35
+ const next = files.map(snapshot)
36
+ if (next.some((content, index) => content !== last[index])) {
37
+ last = next
38
+ reload()
18
39
  }
19
- // persistent: false, so a watcher alone never keeps a one-shot run alive.
20
- fs.watchFile(file, { interval, persistent: false }, listener)
21
- listeners.push([file, listener])
22
- }
23
- return () => {
24
- for (const [file, listener] of listeners) fs.unwatchFile(file, listener)
25
- }
40
+ }, interval)
41
+ // A watcher alone must never keep a one-shot run alive.
42
+ timer.unref?.()
43
+ return () => realClearInterval(timer)
26
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",