pi-code 1.0.23 → 1.0.25

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,10 @@ 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 { claudeMdExcludeFiles, isExcludedPath, readClaudeMdExcludes } from './context-imports.js'
24
25
  import { claudeConfigDir } from './internal/config-dir.js'
25
26
  import { publishInstructionLoad } from './internal/instruction-events.js'
27
+ import { readManagedSettings } from './internal/managed-settings.js'
26
28
  import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
27
29
  import { isProjectApproved } from './internal/project-approval.js'
28
30
  import { findNearestDir } from './internal/project-root.js'
@@ -147,12 +149,36 @@ interface RuleSet {
147
149
 
148
150
  const EMPTY_RULES: RuleSet = { inline: [], scoped: [] }
149
151
 
152
+ /** The canonical form of a path. A target that does not exist yet (a write
153
+ * creating a new file) canonicalises its nearest existing ancestor and keeps the
154
+ * remaining segments, so both sides of the attach match compare realpaths even
155
+ * for brand-new files in a symlinked checkout. */
156
+ function realpathOr(target: string): string {
157
+ try {
158
+ return fs.realpathSync(target)
159
+ } catch {
160
+ const dir = path.dirname(target)
161
+ if (dir === target) return target
162
+ return path.join(realpathOr(dir), path.basename(target))
163
+ }
164
+ }
165
+
150
166
  /** Unscoped rules are inlined; path-scoped ones keep their scope as pointers,
151
- * mirroring Claude Code, where scoped rules attach only to matching files. */
152
- function readRules(rulesDir: string): RuleSet {
167
+ * mirroring Claude Code, where scoped rules attach only to matching files. Files
168
+ * matching `claudeMdExcludes` are skipped entirely, as the docs' monorepo recipe
169
+ * (excluding another team's `.claude/rules/**`) relies on; the check runs on the
170
+ * realpath so a symlink cannot dodge an exclusion. */
171
+ function readRules(rulesDir: string, isExcluded?: (realPath: string) => boolean): RuleSet {
153
172
  const inline: string[] = []
154
173
  const scoped: ScopedRule[] = []
155
174
  for (const file of findMarkdownFiles(rulesDir)) {
175
+ if (isExcluded) {
176
+ // Both spellings count: a glob written against the lexical path and one
177
+ // written against the resolved real path each exclude, which can only
178
+ // widen an exclusion, never dodge one.
179
+ const lexical = path.join(rulesDir, file)
180
+ if (isExcluded(lexical) || isExcluded(realpathOr(lexical))) continue
181
+ }
156
182
  let parsed: Frontmatter
157
183
  try {
158
184
  parsed = parseFrontmatter(fs.readFileSync(path.join(rulesDir, file), 'utf-8'))
@@ -223,15 +249,20 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
223
249
  let attachTargets: AttachTarget[] = []
224
250
 
225
251
  pi.on('session_start', async (_event, ctx) => {
226
- globalRules = readRules(globalRulesDir)
227
252
  // Project rules are repository text landing in the system prompt, so they load
228
253
  // only once the project is approved. isProjectTrusted alone is true for a repo
229
254
  // pi never asked about; see project-approval.
230
255
  const approved = await isProjectApproved(ctx)
256
+ // Claude's claudeMdExcludes covers rules files too (the docs' monorepo recipe
257
+ // excludes another team's .claude/rules/**), so the same merged glob list the
258
+ // context loader honors gates rule files here.
259
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, os.homedir(), approved), readManagedSettings())
260
+ const isExcluded = (realPath: string): boolean => isExcludedPath(realPath, excludeGlobs, os.homedir())
261
+ globalRules = readRules(globalRulesDir, isExcluded)
231
262
  // Nearest at-or-above cwd, so a subdirectory session still reads the rules the
232
263
  // approval walk gated on.
233
264
  const projectRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
234
- projectRules = projectRulesDir ? readRules(projectRulesDir) : EMPTY_RULES
265
+ projectRules = projectRulesDir ? readRules(projectRulesDir, isExcluded) : EMPTY_RULES
235
266
 
236
267
  // Global globs are relative to cwd; project globs to the project root (the dir
237
268
  // holding .claude), so `db/**` in a repo rule matches repo-relative paths even
@@ -239,8 +270,8 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
239
270
  // than on every tool result; rebuilt per session so a re-run re-attaches.
240
271
  const projectRoot = projectRulesDir ? path.dirname(path.dirname(projectRulesDir)) : ctx.cwd
241
272
  attachTargets = [
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 })),
273
+ ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(ctx.cwd), file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
274
+ ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
244
275
  ]
245
276
  pendingScopedRules = attachTargets.length
246
277
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
@@ -274,7 +305,9 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
274
305
  if (event.toolName !== 'read' && event.toolName !== 'edit' && event.toolName !== 'write') return
275
306
  const rel = (event.input as { path?: unknown } | undefined)?.path
276
307
  if (typeof rel !== 'string' || rel.length === 0) return
277
- const abs = path.resolve(ctx.cwd, rel)
308
+ // Realpath both sides (roots canonicalise at session_start): a tool reporting
309
+ // the resolved real path in a symlinked checkout must still match.
310
+ const abs = realpathOr(path.resolve(ctx.cwd, rel))
278
311
 
279
312
  const bodies: string[] = []
280
313
  const remaining: AttachTarget[] = []
@@ -95,7 +95,7 @@ import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } fro
95
95
  import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
96
96
  import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
97
97
  import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
98
- import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
98
+ import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
99
99
 
100
100
  export * from './config.js'
101
101
  export * from './decisions.js'
@@ -185,6 +185,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
185
185
  if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
186
186
  return common
187
187
  }
188
+ /** Claude's prompt-hook `model` override, resolved against the models this user
189
+ * can run (exact id first, then a substring match); the session model otherwise. */
190
+ const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => {
191
+ if (!override) return ctx.model
192
+ const available = (ctx as { modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? []
193
+ const needle = override.toLowerCase()
194
+ const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
195
+ return (match as ExtensionContext['model']) ?? ctx.model
196
+ }
197
+
188
198
  /** Kills for background hooks still running; Claude kills async hooks at teardown,
189
199
  * so session_shutdown reaps anything left rather than let a hung hook pin the
190
200
  * event loop past a one-shot run's end. */
@@ -220,7 +230,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
220
230
  const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
221
231
  const dispatch = (onChild?: (kill: () => void) => void): Promise<HookRunResult> => {
222
232
  if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
223
- if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
233
+ if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
224
234
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
225
235
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
226
236
  return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
@@ -483,6 +493,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
483
493
  stopHookActive = false
484
494
  return
485
495
  }
496
+ // Claude: Stop does not run when the stoppage was a user interrupt; pi marks
497
+ // the aborted turn's final assistant message stopReason "aborted".
498
+ const turnMessages = (event as { messages?: Array<{ role: string; stopReason?: string }> }).messages ?? []
499
+ const lastAssistant = [...turnMessages].reverse().find((message) => message.role === 'assistant')
500
+ if (lastAssistant?.stopReason === 'aborted') {
501
+ stopHookActive = false
502
+ return
503
+ }
486
504
  // Claude's Stop payload carries the turn's final assistant text so a hook need
487
505
  // not re-read the transcript; included only when there is one.
488
506
  const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
@@ -543,7 +561,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
543
561
 
544
562
  pi.on('session_shutdown', async (event, ctx) => {
545
563
  const reason = claudeSpelling(SESSION_END_REASON, event.reason)
546
- const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
564
+ // SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
565
+ // slow hook cannot stall session exit, /new or /resume.
566
+ const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
567
+ const runner = boundRunner(ctx)
568
+ const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
547
569
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
548
570
  // Claude kills async hooks still running at teardown; the session that spawned
549
571
  // these is over, and their delivery would target a disposed context anyway.
@@ -20,6 +20,12 @@ import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
20
20
  // raise their own per-hook `timeout`.
21
21
  const DEFAULT_TIMEOUT_S = 60
22
22
 
23
+ /** Claude's per-type defaults where they are safe to mirror: 30s for `prompt`
24
+ * hooks and 60s for `agent` hooks. Command/http/mcp_tool keep the flat 60s
25
+ * documented divergence from Claude's 600 (a gated hook fails closed here, so ten
26
+ * minutes of default budget would wedge the turn). */
27
+ const TYPE_DEFAULT_TIMEOUT_S: Record<string, number> = { prompt: 30, agent: 60 }
28
+
23
29
  export interface HookRunResult {
24
30
  code: number
25
31
  stdout: string
@@ -49,10 +55,20 @@ export function timeoutMs(command: HookCommand): number {
49
55
  // Non-positive values fall back to the default: a 0ms timer would fire before the
50
56
  // hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
51
57
  const declared = command.timeout
52
- const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
58
+ const fallback = TYPE_DEFAULT_TIMEOUT_S[command.type ?? 'command'] ?? DEFAULT_TIMEOUT_S
59
+ const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : fallback
53
60
  return seconds * 1000
54
61
  }
55
62
 
63
+ /** Claude's SessionEnd budget: hooks share 1.5 seconds so session exit (and /new,
64
+ * /resume) cannot stall on a slow hook; a declared per-hook `timeout` raises the
65
+ * budget to match, up to 60 seconds. */
66
+ export function sessionEndTimeoutMs(command: HookCommand): number {
67
+ const declared = command.timeout
68
+ if (typeof declared === 'number' && declared > 0) return Math.min(declared, 60) * 1000
69
+ return 1500
70
+ }
71
+
56
72
  /** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
57
73
  const MAX_HOOK_OUTPUT = 1_000_000
58
74
 
@@ -217,7 +233,11 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
217
233
  if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
218
234
  // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
219
235
  // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
220
- const prompt = substituteArguments(hook.prompt, payload)
236
+ // Claude: when $ARGUMENTS is not present, the input JSON is appended to the
237
+ // prompt, so the model never evaluates blind.
238
+ const template = hook.prompt ?? ''
239
+ const withInput = template.includes('$ARGUMENTS') ? template : `${template}\n\n$ARGUMENTS`
240
+ const prompt = substituteArguments(withInput, payload)
221
241
  const signal = AbortSignal.timeout(timeoutMs)
222
242
  try {
223
243
  const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
@@ -7,8 +7,10 @@
7
7
  * root (the settings source), and bare or `./` from the current directory. As
8
8
  * allow rules, a single-segment directory pattern anchors at cwd; a bare
9
9
  * filename matches at any depth. `*` stays within one segment, `**` crosses
10
- * directories. Matching is lexical, on resolved paths; bracket expressions are
11
- * not supported and match literally, which can only over-block, never widen.
10
+ * directories. Matching is lexical, on resolved paths. Bracket expressions parse
11
+ * per Claude's documented glob contract: `[abc]` classes with ranges and `!`
12
+ * negation, an unreadable `[` making the pattern match nothing, and `\[` for a
13
+ * literal bracket.
12
14
  */
13
15
 
14
16
  import * as path from 'node:path'
@@ -79,26 +81,63 @@ function expandBraces(pattern: string): string[] | null {
79
81
  return expanded
80
82
  }
81
83
 
82
- /** One glob pattern, braces already expanded, as a regular expression source. */
83
- function translateGlob(pattern: string): string {
84
+ /** The end index of a bracket expression starting at `start` (`[`), or -1 when it
85
+ * cannot be read as one, which per Claude makes the whole pattern invalid. A `]`
86
+ * directly after the opening (or after a leading negation) is a literal member. */
87
+ function bracketEnd(pattern: string, start: number): number {
88
+ let i = start + 1
89
+ if (pattern[i] === '!' || pattern[i] === '^') i += 1
90
+ if (pattern[i] === ']') i += 1
91
+ for (; i < pattern.length; i += 1) {
92
+ if (pattern[i] === ']') return i
93
+ }
94
+ return -1
95
+ }
96
+
97
+ /** A bracket expression body as a regex character class, escaping regex-relevant
98
+ * characters while keeping `-` ranges; a leading `!` (or `^`) negates. */
99
+ function bracketClass(body: string): string {
100
+ const negated = body.startsWith('!') || body.startsWith('^')
101
+ const members = (negated ? body.slice(1) : body).replace(/[\\\]^]/g, (ch) => `\\${ch}`)
102
+ return `[${negated ? '^' : ''}${members}]`
103
+ }
104
+
105
+ /** A `*` run starting at `i`: a double star followed by a slash spans whole
106
+ * directories, a bare double star crosses segments, and a single `*` stays within
107
+ * one. Returns the regex source and the index after the run. */
108
+ function translateStar(pattern: string, i: number): { source: string; next: number } {
109
+ if (pattern[i + 1] === '*') {
110
+ const prevSlash = i === 0 || pattern[i - 1] === '/'
111
+ if (prevSlash && pattern[i + 2] === '/') return { source: '(?:[^/]+/)*', next: i + 3 }
112
+ return { source: '.*', next: i + 2 }
113
+ }
114
+ return { source: '[^/]*', next: i + 1 }
115
+ }
116
+
117
+ function translateGlob(pattern: string): string | null {
84
118
  let out = ''
85
119
  let i = 0
86
120
  while (i < pattern.length) {
87
121
  const ch = pattern[i]
122
+ // Claude: to match a literal bracket, escape it; the escape consumes both chars.
123
+ if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) {
124
+ out += escapeRegExp(pattern[i + 1])
125
+ i += 2
126
+ continue
127
+ }
128
+ // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be
129
+ // read as one makes the pattern invalid, matching nothing.
130
+ if (ch === '[') {
131
+ const end = bracketEnd(pattern, i)
132
+ if (end === -1) return null
133
+ out += bracketClass(pattern.slice(i + 1, end))
134
+ i = end + 1
135
+ continue
136
+ }
88
137
  if (ch === '*') {
89
- if (pattern[i + 1] === '*') {
90
- const prevSlash = i === 0 || pattern[i - 1] === '/'
91
- if (prevSlash && pattern[i + 2] === '/') {
92
- out += '(?:[^/]+/)*' // `**/` spans zero or more whole directories
93
- i += 3
94
- continue
95
- }
96
- out += '.*'
97
- i += 2
98
- continue
99
- }
100
- out += '[^/]*'
101
- i += 1
138
+ const star = translateStar(pattern, i)
139
+ out += star.source
140
+ i = star.next
102
141
  continue
103
142
  }
104
143
  if (ch === '?') {
@@ -112,13 +151,20 @@ function translateGlob(pattern: string): string {
112
151
  return out
113
152
  }
114
153
 
154
+ /** A regex source that matches nothing: the compiled form of an invalid pattern. */
155
+ const NEVER_MATCH = '(?!)'
156
+
115
157
  /** One gitignore-style pattern as an anchored regular expression source. Brace
116
158
  * groups (`{ts,tsx}`, nested, Cartesian across groups) expand into ORed
117
- * alternatives; an over-budget expansion falls back to the literal pattern. */
159
+ * alternatives; an over-budget expansion falls back to the literal pattern. An
160
+ * invalid pattern (an unreadable bracket expression) matches nothing, as Claude
161
+ * documents, rather than matching its literal spelling. */
118
162
  export function globToRegExpSource(pattern: string): string {
119
163
  const alternatives = expandBraces(pattern) ?? [pattern]
120
- if (alternatives.length === 1) return translateGlob(alternatives[0])
121
- return `(?:${alternatives.map(translateGlob).join('|')})`
164
+ const sources = alternatives.map(translateGlob)
165
+ if (sources.includes(null)) return NEVER_MATCH
166
+ if (sources.length === 1) return sources[0] as string
167
+ return `(?:${sources.join('|')})`
122
168
  }
123
169
 
124
170
  /** A rule resolved to an absolute glob per its anchor form. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
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",