pi-code 1.0.63 → 1.0.64

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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +3 -3
  4. package/extensions/git-checkpoint.ts +137 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +13 -9
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +7 -28
  10. package/extensions/internal/agent-run.ts +1 -1
  11. package/extensions/internal/bash-rules.ts +1 -2
  12. package/extensions/internal/claude-tool-names.ts +4 -7
  13. package/extensions/internal/command-file.ts +2 -3
  14. package/extensions/internal/external-imports.ts +4 -5
  15. package/extensions/internal/goal-evaluator.ts +4 -3
  16. package/extensions/internal/instruction-events.ts +5 -5
  17. package/extensions/internal/managed-settings.ts +1 -1
  18. package/extensions/internal/mcp-oauth.ts +13 -3
  19. package/extensions/internal/model-complete.ts +7 -1
  20. package/extensions/internal/model-lookup.ts +10 -3
  21. package/extensions/internal/path-rules.ts +10 -13
  22. package/extensions/internal/plugins.ts +5 -5
  23. package/extensions/internal/process-tree.ts +39 -0
  24. package/extensions/internal/project-approval.ts +7 -7
  25. package/extensions/internal/project-root.ts +13 -13
  26. package/extensions/internal/settings-chain.ts +19 -0
  27. package/extensions/internal/settings-watch.ts +3 -1
  28. package/extensions/internal/shell-resolve.ts +3 -1
  29. package/extensions/internal/tool-target.ts +4 -5
  30. package/extensions/internal/values.ts +20 -4
  31. package/extensions/mcp/config.ts +11 -4
  32. package/extensions/mcp/index.ts +40 -4
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +2 -8
  35. package/extensions/memory.ts +4 -4
  36. package/extensions/notify.ts +2 -7
  37. package/extensions/output-styles.ts +22 -17
  38. package/extensions/question.ts +2 -7
  39. package/extensions/skills.ts +5 -5
  40. package/extensions/subagent/agents.ts +4 -4
  41. package/extensions/subagent/background.ts +35 -41
  42. package/extensions/subagent/child.ts +2 -2
  43. package/extensions/subagent/modes.ts +18 -13
  44. package/extensions/subagent/params.ts +1 -2
  45. package/extensions/subagent/run.ts +3 -14
  46. package/extensions/thinking.ts +3 -3
  47. package/extensions/web.ts +3 -4
  48. package/package.json +1 -1
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * The one place pi tool names and Claude tool names are paired.
3
3
  *
4
- * Two independent tables used to encode this: a Claude->pi map for resolving
5
- * `allowed-tools` entries, and a pi->Claude map for hook payloads and matchers.
6
- * They disagreed. The second one listed six tools, so a hook matcher written in
7
- * Claude's vocabulary never fired for `web_fetch`, `subagent`, `question`, `todo`,
8
- * `slash_command`, `web_search` or `plan_mode_complete`, even though the first table
9
- * had known those pairings all along. Both directions now derive from this list, so
10
- * a tool cannot be nameable in one direction and invisible in the other.
4
+ * Both directions (the Claude->pi map that resolves `allowed-tools` entries, and the
5
+ * pi->Claude map behind hook payloads and matchers) derive from this one list, so a
6
+ * tool cannot be nameable in one direction and invisible in the other: two separate
7
+ * tables let a hook matcher written in Claude's vocabulary miss seven tools.
11
8
  *
12
9
  * Canonical names are the tools reference's own spellings. `claude: null` marks a pi
13
10
  * tool the reference has no counterpart for: it stays untranslated in payloads, and
@@ -19,6 +19,7 @@ import * as path from 'node:path'
19
19
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
20
20
 
21
21
  import { CLAUDE_TOOL_MAP } from './claude-tool-names.js'
22
+ import { escapeRegExp } from './values.js'
22
23
 
23
24
  /** The pi file tools a Claude path rule can govern. */
24
25
  export type PathRuleTool = 'read' | 'edit' | 'write'
@@ -256,7 +257,7 @@ const text = (value: unknown): string => {
256
257
  * number rather than a boolean. A flag that gates a command off from the model has to
257
258
  * honor them, or a command the user marked off-limits is silently offered to it. */
258
259
  const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
259
- const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
260
+ export const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
260
261
 
261
262
  /** YAML's negative boolean spellings, the mirror of YAML_TRUE. A flag that defaults to
262
263
  * true (user-invocable) is turned off only by one of these; any other value, absent
@@ -332,8 +333,6 @@ function splitArgs(args: string): string[] {
332
333
  return out
333
334
  }
334
335
 
335
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
336
-
337
336
  /** One alternation covering every argument placeholder plus the two escape forms.
338
337
  * Alternation order is load-bearing: escapes first (so `\$1` never expands), the
339
338
  * bracketed forms before `$ARGUMENTS` (so `$ARGUMENTS[0]` is not read as the bare
@@ -11,10 +11,9 @@
11
11
  * root, or the resolved working directory outside a repository.
12
12
  *
13
13
  * The key is the git root and not the project root because a repository must not be
14
- * able to move its own key. The project root also stops at package.json, so declining
15
- * at the top of a monorepo and starting the next session inside a package produced a
16
- * different key and asked again, which is not a decision that was kept. A separate
17
- * worktree of one repository is a separate checkout and is asked separately.
14
+ * able to move its own key: `.git` cannot be committed, so nothing a repository ships
15
+ * changes where its own decisions are stored. A separate worktree of one repository
16
+ * is a separate checkout and is asked separately.
18
17
  */
19
18
 
20
19
  import * as fs from 'node:fs'
@@ -37,7 +36,7 @@ export function externalImportKey(cwd: string): string {
37
36
  }
38
37
  }
39
38
 
40
- /** The store file. A seam: the tests point it at a temp directory. */
39
+ /** The store file, inside pi's agent directory (tests relocate that through PI_CODING_AGENT_DIR). */
41
40
  export function externalImportStorePath(agentDir: string = getAgentDir()): string {
42
41
  return path.join(agentDir, 'pi-code-external-imports.json')
43
42
  }
@@ -6,6 +6,8 @@
6
6
  * so goal.ts stays the lifecycle wiring and each contract is pinned on its own.
7
7
  */
8
8
 
9
+ import { parseNumericEnv } from './values.js'
10
+
9
11
  /** Claude caps a goal condition at 4,000 characters. */
10
12
  export const GOAL_CONDITION_MAX_CHARS = 4000
11
13
 
@@ -238,9 +240,8 @@ export const MAX_IDLE_CHECKINS = 3
238
240
  * goal: CLAUDE_CODE_GOAL_CHECKIN_MINUTES (0 turns check-ins off, junk falls back to
239
241
  * the default) scaled by Claude's doubling. */
240
242
  export function checkinIntervalMs(env: Record<string, string | undefined>, delivered: number): number {
241
- const raw = env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES
242
- const parsed = raw === undefined || raw.trim() === '' ? Number.NaN : Number(raw)
243
- const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CHECKIN_MINUTES
243
+ const parsed = parseNumericEnv(env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES)
244
+ const minutes = parsed !== undefined && parsed >= 0 ? parsed : DEFAULT_CHECKIN_MINUTES
244
245
  return minutes * 60_000 * 2 ** Math.min(delivered, MAX_CHECKIN_DOUBLINGS)
245
246
  }
246
247
 
@@ -49,11 +49,11 @@ export function memoryTypeForPath(filePath: string, home: string, projectRoot: s
49
49
  if (path.basename(filePath) === 'CLAUDE.local.md') return 'Local'
50
50
  const isUnder = (root: string): boolean => root.length > 0 && (filePath === root || filePath.startsWith(root + path.sep))
51
51
  if (isUnder(projectRoot)) return 'Project'
52
- // Monorepo: repoRoot stops at the nearest .git OR package.json, so a git-root
53
- // CLAUDE.md can sit above the projectRoot a subpackage session reports. A file
54
- // whose directory is a strict ancestor of the project root is still project
55
- // memory. The home directory itself stays User: a home-level context file is
56
- // user config even when the project lives under home.
52
+ // Nested repositories: repoRoot stops at the nearest .git, so a session inside a
53
+ // nested checkout reports it as the project root while the outer repository's
54
+ // CLAUDE.md sits above it. A file whose directory is a strict ancestor of the
55
+ // project root is still project memory. The home directory itself stays User: a
56
+ // home-level context file is user config even when the project lives under home.
57
57
  const dir = path.dirname(filePath)
58
58
  if (dir !== home && (projectRoot === dir || projectRoot.startsWith(dir + path.sep))) return 'Project'
59
59
  if (isUnder(home)) return 'User'
@@ -17,7 +17,7 @@ import { isRecord } from './values.js'
17
17
  /** The OS managed-settings.json path Claude Code documents per platform. */
18
18
  export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
19
19
  if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
20
- // The legacy C:\ProgramData\ClaudeCode path was dropped in Claude Code v2.1.75.
20
+ // Claude reads the Windows file from Program Files; the older ProgramData location is not consulted.
21
21
  if (platform === 'win32') return String.raw`C:\Program Files\ClaudeCode\managed-settings.json`
22
22
  return '/etc/claude-code/managed-settings.json'
23
23
  }
@@ -61,8 +61,8 @@ function storeFileFor(serverName: string, endpoint?: string): string {
61
61
  .digest('hex')
62
62
  .slice(0, 8)
63
63
  // Collapse disallowed runs to a single hyphen, then strip leading and trailing
64
- // hyphens by index. The old /^-+|-+$/g trim rescanned on every hyphen of a long run
65
- // (its trailing-anchored branch backtracks per start position), which is quadratic.
64
+ // hyphens by index: a trailing-anchored regex trim rescans per hyphen of a long run
65
+ // (quadratic).
66
66
  const collapsed = serverName.replace(/[^A-Za-z0-9_-]+/g, '-')
67
67
  let start = 0
68
68
  let end = collapsed.length
@@ -248,7 +248,17 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number, expected
248
248
  // abandoned or resolved out of band, the event loop can still drain.
249
249
  timer.unref?.()
250
250
  server.on('request', (request, response) => {
251
- const url = new URL(request.url ?? '/', 'http://127.0.0.1')
251
+ // Node's parser passes an absolute-form target through unvalidated, and a throw
252
+ // from a 'request' listener is not caught by node:http: it was an
253
+ // uncaughtException during the login window, from any local process that could
254
+ // reach the port.
255
+ let url: URL
256
+ try {
257
+ url = new URL(request.url ?? '/', 'http://127.0.0.1')
258
+ } catch {
259
+ response.writeHead(400).end()
260
+ return
261
+ }
252
262
  // Only the redirect path settles the login. A stray request (a favicon fetch, a
253
263
  // local port scan, or a forged redirect from another process or an open web page)
254
264
  // is answered but ignored, so it can neither inject a code nor abort the login by
@@ -59,7 +59,13 @@ export interface CompleteOptions {
59
59
  * tool call, only assistant text.
60
60
  */
61
61
  export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<{ text: string; usage: Usage }> {
62
- backend ??= realBackend()
62
+ // A rejected creation must not stay cached: ModelRuntime.create can fail on a
63
+ // transient (a credential store read), and caching that promise made every later
64
+ // call rethrow the same stale error for the life of the process.
65
+ backend ??= realBackend().catch((error: unknown) => {
66
+ backend = null
67
+ throw error
68
+ })
63
69
  const complete = await backend
64
70
  const context: Context = {
65
71
  systemPrompt: options.system,
@@ -11,10 +11,17 @@ export interface ModelLookupContext<M> {
11
11
  modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> }
12
12
  }
13
13
 
14
+ /** The one fuzzy model rule every surface shares (goal, prompt hooks, commands,
15
+ * subagent aliases): an exact id (case-insensitive) first, then a substring of the
16
+ * id or the display name. Three private copies had diverged, so `model: opus` could
17
+ * resolve differently per surface. */
18
+ export function findModel<M extends { id: string; name?: string }>(needle: string, available: ReadonlyArray<M>): M | undefined {
19
+ const wanted = needle.toLowerCase()
20
+ return available.find((model) => model.id.toLowerCase() === wanted) ?? available.find((model) => model.id.toLowerCase().includes(wanted) || model.name?.toLowerCase().includes(wanted))
21
+ }
22
+
14
23
  export function resolveModelOverride<M>(ctx: ModelLookupContext<M>, override: string | undefined): M | undefined {
15
24
  if (!override) return ctx.model
16
25
  const available = ctx.modelRegistry?.getAvailable?.() ?? []
17
- const needle = override.toLowerCase()
18
- const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
19
- return (match as M | undefined) ?? ctx.model
26
+ return (findModel(override, available) as M | undefined) ?? ctx.model
20
27
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import * as path from 'node:path'
17
+ import { escapeRegExp } from './values.js'
17
18
 
18
19
  export interface PathAnchors {
19
20
  cwd: string
@@ -21,8 +22,6 @@ export interface PathAnchors {
21
22
  home: string
22
23
  }
23
24
 
24
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
25
-
26
25
  /** Cap on brace-expanded alternatives per pattern, mirroring Claude's ~1000
27
26
  * budget; an over-budget pattern is used unexpanded. */
28
27
  const BRACE_EXPANSION_LIMIT = 1000
@@ -104,9 +103,9 @@ function bracketClass(body: string): string | null {
104
103
  // JavaScript will build: RegExp throws "Range out of order in character class". The
105
104
  // `-` cannot simply be escaped, since `[a-z]` is the whole point of the syntax, so the
106
105
  // class is validated by construction and an unbuildable one is treated exactly as an
107
- // unterminated `[` already is: the pattern is invalid and matches nothing. Without
108
- // this the throw escaped compileGlobs, which does not catch, and took the permission
109
- // check that called it with it.
106
+ // unterminated `[` already is: the pattern is invalid and matches nothing. It must
107
+ // not throw: compileGlobs does not catch, and the permission check that called it
108
+ // would fail with it.
110
109
  return isBuildableClass(source) ? source : null
111
110
  }
112
111
 
@@ -196,9 +195,7 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
196
195
  return path.join(anchors.cwd, rel)
197
196
  }
198
197
 
199
- /** One rule glob precompiled for repeated matching: its anchored regex, and whether
200
- * it applies to the basename (a slashless pattern, gitignore-style) or the full
201
- * root-relative path. */
198
+ /** One rule glob precompiled for repeated matching: its anchored regex. */
202
199
  export interface CompiledGlob {
203
200
  regex: RegExp
204
201
  }
@@ -212,13 +209,13 @@ export function globCompileStats(): { compiled: number; evaluated: number } {
212
209
  return { compiled: globsCompiled, evaluated: globsEvaluated }
213
210
  }
214
211
 
215
- /** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
216
- * pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
217
- * slash scopes to the directory's contents, and blank entries drop out. */
218
212
  /** Claude's shared list budget: rule patterns past ~1000 compiled entries are
219
213
  * ignored rather than compiled without bound. */
220
214
  const LIST_PATTERN_BUDGET = 1000
221
215
 
216
+ /** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
217
+ * pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
218
+ * slash scopes to the directory's contents, and blank entries drop out. */
222
219
  export function compileGlobs(globs: string[]): CompiledGlob[] {
223
220
  const compiled: CompiledGlob[] = []
224
221
  for (const raw of globs) {
@@ -244,8 +241,6 @@ export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): bo
244
241
  return globs.some((glob) => glob.regex.test(posix))
245
242
  }
246
243
 
247
- /** Whether the accessed file matches at least one rule. No rules means no match:
248
- * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
249
244
  /** Both comparison sides in posix form. On Windows, resolve stamps the drive on
250
245
  * the target while join-built rules stay drive-less, and backslash separators
251
246
  * collide with glob syntax, so unnormalized rules could never match. */
@@ -273,6 +268,8 @@ export function stripRuleDrive(rule: string, windows: boolean): string {
273
268
  return rule.replace(/^([A-Za-z]):\//, '/').replace(/^\/[A-Za-z]\//, '/')
274
269
  }
275
270
 
271
+ /** Whether the accessed file matches at least one rule. No rules means no match:
272
+ * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
276
273
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
277
274
  const target = toPosix(path.resolve(anchors.cwd, filePath))
278
275
  return rules.some((rule) => {
@@ -258,11 +258,6 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
258
258
  return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
259
259
  }
260
260
 
261
- /** The two plugin path variables, textually substituted into plugin-shipped
262
- * config (hook commands, MCP server definitions, command bodies). A caller
263
- * substituting into text that is still raw JSON must pass an escapeValue that
264
- * JSON-escapes: a Windows root (C:\Users\...) inserted verbatim injects invalid
265
- * escape sequences and the subsequent parse throws. */
266
261
  /** A plugin component path, resolved inside the plugin root. Claude "rejects a component
267
262
  * path that resolves outside the plugin root, such as `../shared-utils`", so an escaping
268
263
  * entry yields undefined and its caller skips that component. The check is lexical, which
@@ -277,6 +272,11 @@ export function pluginComponentPath(plugin: Pick<InstalledPlugin, 'name' | 'root
277
272
  return resolved
278
273
  }
279
274
 
275
+ /** The two plugin path variables, textually substituted into plugin-shipped
276
+ * config (hook commands, MCP server definitions, command bodies). A caller
277
+ * substituting into text that is still raw JSON must pass an escapeValue that
278
+ * JSON-escapes: a Windows root (C:\Users\...) inserted verbatim injects invalid
279
+ * escape sequences and the subsequent parse throws. */
280
280
  export function substitutePluginVars(value: string, plugin: InstalledPlugin, escapeValue: (substituted: string) => string = (substituted) => substituted): string {
281
281
  return value
282
282
  .replaceAll('${CLAUDE_PLUGIN_ROOT}', escapeValue(plugin.root))
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Kill a detached child and everything it spawned. `sh -c 'a; b'` forks, and so does
3
+ * a child pi running a build, so signalling the direct child alone leaves a grandchild
4
+ * alive holding stdout/stderr. One copy for hooks and both subagent runners: the three
5
+ * private copies had drifted, and only the hooks one handled Windows, so a subagent
6
+ * cancel there killed the direct child only.
7
+ */
8
+
9
+ import { type ChildProcess, spawn } from 'node:child_process'
10
+ import * as path from 'node:path'
11
+
12
+ export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals, platform: NodeJS.Platform = process.platform): void {
13
+ if (platform === 'win32') {
14
+ // Windows has no process groups: taskkill /T ends the whole tree, and it has no
15
+ // graceful signal, so SIGTERM and SIGKILL both force. By absolute path, so a
16
+ // writable PATH entry cannot stand in for it. If taskkill itself cannot start, or
17
+ // there is no pid to hand it, the direct kill with the requested signal is all
18
+ // that is left (Node maps either signal to TerminateProcess there).
19
+ const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
20
+ if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill(signal))
21
+ else child.kill(signal)
22
+ return
23
+ }
24
+ try {
25
+ // Negative pid targets the whole process group, which `detached` gave the child.
26
+ // A child that never spawned has no pid and no group; the direct kill is all there is.
27
+ if (child.pid) {
28
+ process.kill(-child.pid, signal)
29
+ return
30
+ }
31
+ } catch {
32
+ // Group already reaped, or the platform refused it; fall through to the direct kill.
33
+ }
34
+ try {
35
+ child.kill(signal)
36
+ } catch {
37
+ // already gone
38
+ }
39
+ }
@@ -118,13 +118,6 @@ function runtimeLacksProjectTrust(ctx: { isProjectTrusted?: () => boolean; ui?:
118
118
  return true
119
119
  }
120
120
 
121
- /**
122
- * Whether project-controlled config may be acted on.
123
- *
124
- * Refuses without a UI rather than deferring: pi reached this point without consulting
125
- * `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
126
- * that cannot ask has not been approved.
127
- */
128
121
  /** The same decision as isProjectApproved, but never prompts: an undecided project
129
122
  * reads as unapproved. For surfaces that only display project config, like the
130
123
  * subagent roster, where a mid-turn dialog would be wrong. */
@@ -150,6 +143,13 @@ export function isGatedFileApproved(ctx: Pick<ApprovalContext, 'cwd' | 'isProjec
150
143
  return isProjectApprovedSilently(ctx, { ...deps, hasClaudeShaped: () => true })
151
144
  }
152
145
 
146
+ /**
147
+ * Whether project-controlled config may be acted on.
148
+ *
149
+ * Refuses without a UI rather than deferring: pi reached this point without consulting
150
+ * `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
151
+ * that cannot ask has not been approved.
152
+ */
153
153
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
154
154
  if (runtimeLacksProjectTrust(ctx)) return false // pi predates project-trust support
155
155
  if (ctx.isProjectTrusted?.() !== true) return false // pi declined trust for this project
@@ -16,11 +16,10 @@ import * as path from 'node:path'
16
16
  /** The project root marker. `.git` is a file in worktrees and submodules, a directory
17
17
  * in an ordinary clone.
18
18
  *
19
- * `package.json` used to count too, which made every package of a monorepo its own
20
- * project: its own memory directory, its own settings.local.json, its own
21
- * CLAUDE_PROJECT_DIR, its own trust decision. Claude's project is the repository, and
22
- * a repository can add a package.json wherever it likes, so a marker it controls was
23
- * also a marker it could move. */
19
+ * Only `.git`: a package.json marker would make every package of a monorepo its own
20
+ * project (its own memory directory, settings.local.json, CLAUDE_PROJECT_DIR and trust
21
+ * decision), and a repository can add a package.json wherever it likes, so a marker it
22
+ * controls is a marker it can move. Claude's project is the repository. */
24
23
  export const ROOT_MARKERS = ['.git']
25
24
 
26
25
  /** Project root at or above `from`, or undefined outside a repository. */
@@ -32,11 +31,12 @@ export function repoRoot(from: string): string | undefined {
32
31
 
33
32
  /** The git checkout at or above `from`, or undefined outside one.
34
33
  *
35
- * Narrower than repoRoot on purpose, and used where a key must be stable rather than
36
- * merely near: repoRoot also stops at package.json, which every package of a monorepo
37
- * ships, so a decision keyed on it changes the moment the session starts one directory
38
- * deeper. `.git` cannot be committed into a repository, so it is not a marker the
39
- * repository can add to move its own key.
34
+ * Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout,
35
+ * which is the right key for shared state (settings.local.json, auto memory) but is a
36
+ * sibling of the worktree, never an ancestor. The upward walks above bound themselves
37
+ * here instead, since a boundary that is not on the path from cwd to / is never reached
38
+ * and the walk would run on to the filesystem root. `.git` cannot be committed into a
39
+ * repository, so it is not a marker the repository can add to move its own key.
40
40
  */
41
41
  export function gitRoot(from: string): string | undefined {
42
42
  let currentDir = from
@@ -89,7 +89,7 @@ function statOf(target: string): fs.Stats | null {
89
89
  }
90
90
 
91
91
  function findNearest(cwd: string, relative: string, wantDir: boolean): string | null {
92
- const boundary = repoRoot(cwd) ?? cwd
92
+ const boundary = gitRoot(cwd) ?? cwd
93
93
  let currentDir = cwd
94
94
  while (true) {
95
95
  const candidate = path.join(currentDir, relative)
@@ -117,7 +117,7 @@ export function findNearestFile(cwd: string, relative: string): string | null {
117
117
  * matching Claude's "every .claude/<kind> between the working directory and the
118
118
  * repository root" discovery where the entry closest to cwd wins a name clash. */
119
119
  export function ancestorDirs(cwd: string, relative: string): string[] {
120
- const boundary = repoRoot(cwd) ?? cwd
120
+ const boundary = gitRoot(cwd) ?? cwd
121
121
  const found: string[] = []
122
122
  let currentDir = cwd
123
123
  while (true) {
@@ -134,7 +134,7 @@ export function ancestorDirs(cwd: string, relative: string): string[] {
134
134
  /** Every `relative` file between the repository root and cwd, ordered root first,
135
135
  * matching Claude's root-down ordering for hierarchy-loaded context. */
136
136
  export function ancestorFiles(cwd: string, relative: string): string[] {
137
- const boundary = repoRoot(cwd) ?? cwd
137
+ const boundary = gitRoot(cwd) ?? cwd
138
138
  const found: string[] = []
139
139
  let currentDir = cwd
140
140
  while (true) {
@@ -52,12 +52,31 @@ export function claudeSettingsChain(cwd: string, home: string, includeProject: b
52
52
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
53
53
  if (!includeProject) return files
54
54
  files.push(path.join(cwd, '.claude', 'settings.json'))
55
+ // Compared as the directory the placement rule returned, not re-derived from a
56
+ // joined path: path.join normalizes separators, so a cwd given POSIX-style on
57
+ // Windows would never equal its own joined form and the legacy entry would repeat.
55
58
  const localDir = localSettingsDir(cwd, home, platform, owned)
56
59
  if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
57
60
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
58
61
  return files
59
62
  }
60
63
 
64
+ /** The settings.local.json the chain reads last, which is also where a setting a
65
+ * command persists (an output-style choice, an MCP consent) must be written for the
66
+ * chain to read it back: a file at any other level is never consulted. */
67
+ export function localSettingsFile(cwd: string, home: string, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string {
68
+ return path.join(localSettingsDir(cwd, home, platform, owned), '.claude', 'settings.local.json')
69
+ }
70
+
71
+ /** One settings file as a JSON object, or undefined when missing, unparseable or not
72
+ * an object: the single-file case of the chain, for the user-only settings a
73
+ * repository must not influence (a notification channel, a question timeout, a
74
+ * retention period). */
75
+ export function readSettingsFile(file: string): Record<string, unknown> | undefined {
76
+ const first = readSettingsChain([file]).next()
77
+ return first.done ? undefined : first.value
78
+ }
79
+
61
80
  /** Every readable settings object in the chain, in order, so the last one a caller
62
81
  * sees for a key is the one that wins. A file that is missing, unparseable, or not a
63
82
  * JSON object is skipped: a corrupt settings.json must not end the chain, or the
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs'
9
+ import { parseNumericEnv } from './values.js'
9
10
 
10
11
  // Captured at module load: the poll must run on real time even under a test's
11
12
  // fake timers (the stat watcher it replaced lived in libuv and was immune too);
@@ -29,7 +30,8 @@ function snapshot(file: string): string | undefined {
29
30
  * small, so re-reading them on the poll is negligible. The interval is
30
31
  * env-tunable for tests. */
31
32
  export function watchSettingsFiles(files: string[], reload: () => void): () => void {
32
- const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
33
+ const configured = parseNumericEnv(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS)
34
+ const interval = configured !== undefined && configured > 0 ? configured : 2000
33
35
  let last = files.map(snapshot)
34
36
  const timer = realSetInterval(() => {
35
37
  const next = files.map(snapshot)
@@ -70,7 +70,9 @@ export function resolvePowershellBinary(platform: string = process.platform, env
70
70
  function isProjectTooling(dir: string, cwd: string): boolean {
71
71
  const relative = path.relative(cwd, dir)
72
72
  if (relative === '') return true
73
- if (relative.startsWith('..') || path.isAbsolute(relative)) return false
73
+ // Lexical containment: `..tools/...` is a directory inside cwd, only `..` itself or
74
+ // `../...` leaves it (the shape claude-rules.ts's containment check spells out).
75
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false
74
76
  return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
75
77
  }
76
78
 
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Which file a tool call touched.
3
3
  *
4
- * Two extensions attach instruction files when a file tool touches a path they cover
5
- * (claude-rules for a path-scoped rule, context-imports for a nested CLAUDE.md), and
6
- * both got the same detail wrong: pi's edit and write tools accept `file_path` as an
7
- * alias for `path`, so a handler reading only `path` did nothing for a model that used
8
- * the alias. One reader, one place to be wrong.
4
+ * pi's read, edit and write tools accept `file_path` as an alias for `path`, so every
5
+ * reader of a file tool's target must accept both, and a handler reading only `path`
6
+ * does nothing for a model that used the alias. This is the one reader (claude-rules,
7
+ * context-imports and the command path-scope guard all go through it).
9
8
  */
10
9
 
11
10
  /** The tools that name a file pi-code acts on. */
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The shapes every extension here needed its own copy of: an error's message, a plain
3
- * object check, whether a path is a directory, and the text of a message content. Each
4
- * was written three to five times with the same body, and the error one appeared in
5
- * seventeen files.
2
+ * The small shared shapes: an error's message, a plain-object check, whether a path
3
+ * is a directory, the text of a message content, a regex escape, a numeric env
4
+ * value. One copy each; a private copy in an extension is the drift to look for.
6
5
  */
7
6
 
8
7
  import * as fs from 'node:fs'
@@ -36,3 +35,20 @@ export function contentText(content: unknown, separator = ''): string {
36
35
  .map((part) => part.text)
37
36
  .join(separator)
38
37
  }
38
+
39
+ /** Escape a string for literal use inside a RegExp. One copy: five private ones had
40
+ * the same body and would have drifted the first time one of them was fixed. */
41
+ export function escapeRegExp(text: string): string {
42
+ return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
43
+ }
44
+
45
+ /** A numeric environment value, or undefined when blank or not a number. Accepts the
46
+ * spellings docs/mcp.md promises (`2e3`, `64_000`); the caller decides the range and
47
+ * whether fractions are meaningful, so no flooring here. */
48
+ export function parseNumericEnv(raw: string | undefined): number | undefined {
49
+ if (raw === undefined) return undefined
50
+ const cleaned = raw.replaceAll('_', '')
51
+ if (cleaned.trim() === '') return undefined
52
+ const value = Number(cleaned)
53
+ return Number.isFinite(value) ? value : undefined
54
+ }
@@ -6,10 +6,12 @@
6
6
  import * as fs from 'node:fs'
7
7
  import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
9
10
  import { claudeConfigDir } from '../internal/config-dir.js'
10
11
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
11
12
  import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
12
13
  import { findNearestFile } from '../internal/project-root.js'
14
+ import { errorMessage } from '../internal/values.js'
13
15
 
14
16
  export interface StdioServerConfig {
15
17
  type?: 'stdio'
@@ -91,7 +93,9 @@ function claudeJsonPath(home: string): string {
91
93
  /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
92
94
  * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
93
95
  export function userConfigPaths(home: string): string[] {
94
- return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
96
+ // mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
97
+ // other agent-directory readers (trust store, OAuth tokens) already follow it.
98
+ return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
95
99
  }
96
100
 
97
101
  /** Project-scoped MCP config, each file the nearest of its name at or above cwd
@@ -107,8 +111,10 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
107
111
  try {
108
112
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
109
113
  Object.assign(servers, parsed.mcpServers ?? {})
110
- } catch {
111
- // missing or invalid file: skip silently, /mcp reports what loaded
114
+ } catch (error) {
115
+ // A missing file is the normal case. A present file that does not parse is
116
+ // not: one trailing comma silently disabled every server in it.
117
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${file}: ${errorMessage(error)}`)
112
118
  }
113
119
  }
114
120
  return servers
@@ -132,7 +138,8 @@ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string,
132
138
  try {
133
139
  const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
134
140
  return claudeJson.projects?.[cwd] ?? {}
135
- } catch {
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${claudeJsonPath(home)}: ${errorMessage(error)}`)
136
143
  return {}
137
144
  }
138
145
  }