pi-code 1.0.62 → 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 (49) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +46 -24
  4. package/extensions/git-checkpoint.ts +141 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +14 -10
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +17 -29
  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 +28 -6
  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 +51 -41
  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 +19 -6
  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 +78 -19
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +12 -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 +142 -12
  39. package/extensions/session-title.ts +5 -0
  40. package/extensions/skills.ts +11 -6
  41. package/extensions/subagent/agents.ts +4 -4
  42. package/extensions/subagent/background.ts +35 -41
  43. package/extensions/subagent/child.ts +19 -4
  44. package/extensions/subagent/modes.ts +18 -13
  45. package/extensions/subagent/params.ts +1 -2
  46. package/extensions/subagent/run.ts +34 -20
  47. package/extensions/thinking.ts +3 -3
  48. package/extensions/web.ts +3 -4
  49. package/package.json +1 -1
@@ -93,7 +93,13 @@ function isRunnableHook(hook: HookCommand): boolean {
93
93
  if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
94
94
  if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
95
95
  if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
96
- return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
96
+ // A command hook needs a non-empty command, and exec-form args must all be strings:
97
+ // spawn('') throws ERR_INVALID_ARG_VALUE and a number has no replaceAll for argument
98
+ // substitution, both inside the runner's Promise executor, so the event's handler
99
+ // rejected instead of the hook reporting spawnFailed.
100
+ if (typeof hook.command !== 'string' || hook.command.length === 0) return false
101
+ if (hook.args !== undefined && !(Array.isArray(hook.args) && hook.args.every((arg) => typeof arg === 'string'))) return false
102
+ return hook.type === undefined || hook.type === 'command'
97
103
  }
98
104
 
99
105
  /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
@@ -188,18 +194,17 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
188
194
 
189
195
  /** One `if` pattern against a call's arguments. Claude evaluates the rule "against
190
196
  * the tool name and arguments together" in permission-rule syntax, so each tool uses
191
- * the same specifier engine its permission rules use:
197
+ * the same specifier engine its permission rules use: a command pattern for Bash, a
198
+ * `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
199
+ * path rule for the file tools. A tool with no specifier syntax matches nothing,
200
+ * which is also what an unparseable rule does.
192
201
  *
193
202
  * PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
194
203
  * same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
195
204
  * differs on purpose (an allow scope requires every segment to match, an `if` filter is
196
205
  * best effort and errs toward running the hook), and merging would hide that. They are
197
206
  * two lists that must agree, so a new scoped tool has to be added in both places; the
198
- * shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
199
- * a command pattern for Bash, a
200
- * `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
201
- * path rule for the file tools. A tool with no specifier syntax matches nothing,
202
- * which is also what an unparseable rule does. */
207
+ * shared roster is ARG_RULE_TOOLS in internal/command-file.ts. */
203
208
  function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
204
209
  const str = (value: unknown): string => (typeof value === 'string' ? value : '')
205
210
  switch (piName) {
@@ -6,11 +6,11 @@
6
6
 
7
7
  import { type ChildProcess, spawn } from 'node:child_process'
8
8
  import * as fs from 'node:fs'
9
- import * as path from 'node:path'
10
9
  import type { Api, Model } from '@earendil-works/pi-ai'
11
10
  import { runAgent } from '../internal/agent-run.js'
12
11
  import { callMcpTool } from '../internal/mcp-call.js'
13
12
  import { completeText } from '../internal/model-complete.js'
13
+ import { killProcessTree } from '../internal/process-tree.js'
14
14
  import { resolveShell } from '../internal/shell-resolve.js'
15
15
  import { errorMessage } from '../internal/values.js'
16
16
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
@@ -41,10 +41,6 @@ export interface HookRunResult {
41
41
  }
42
42
  /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
43
43
  export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
44
- /** The shell path specifically; the statusline reuses it for its own command. With an
45
- * `args` array it becomes the exec path: `command` is spawned directly with those args.
46
- * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
47
- * still running at session end can be reaped (Claude kills async hooks at teardown). */
48
44
  /** How one command hook is spawned, beyond the payload and its budget. Grouped rather
49
45
  * than trailing off the parameter list: the exec form, the shell choice and the
50
46
  * declaring plugin are all per-hook fields that arrive together from one HookCommand. */
@@ -56,6 +52,10 @@ export interface HookSpawnOptions {
56
52
  shell?: string
57
53
  /** The declaring plugin's paths, exported to the child. */
58
54
  plugin?: { root: string; dataDir: string }
55
+ /** Claude: "set automatically to the current session ID in ... hook command
56
+ * subprocesses ... this matches the session_id field in the hook JSON input and
57
+ * is updated on /clear." Absent in a stub context with no session manager. */
58
+ sessionId?: string
59
59
  }
60
60
 
61
61
  export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
@@ -92,30 +92,9 @@ const MAX_HOOK_OUTPUT = 1_000_000
92
92
  /** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
93
93
  const TIMEOUT_EXIT_CODE = 124
94
94
 
95
- /**
96
- * Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
97
- * direct child alone leaves a grandchild alive holding stdout/stderr.
98
- */
95
+ /** Kill the shell and everything it spawned; see internal/process-tree. */
99
96
  function killTree(child: ChildProcess): void {
100
- if (process.platform === 'win32') {
101
- // Windows has no process groups: taskkill /T ends the shell's whole tree. By
102
- // absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
103
- // cannot start, the direct kill is all that is left.
104
- const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
105
- if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
106
- else child.kill('SIGKILL')
107
- return
108
- }
109
- try {
110
- // Negative pid targets the whole process group, which `detached` gave the shell.
111
- if (child.pid) {
112
- process.kill(-child.pid, 'SIGKILL')
113
- return
114
- }
115
- } catch {
116
- // Group already reaped, or the platform refused it; fall through to the direct kill.
117
- }
118
- child.kill('SIGKILL')
97
+ killProcessTree(child, 'SIGKILL')
119
98
  }
120
99
 
121
100
  /** Create the plugin data directory the moment its path is handed to a child. Claude
@@ -136,7 +115,11 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
136
115
  return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
137
116
  }
138
117
 
139
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin } = {}) =>
118
+ /** The shell path specifically; the statusline reuses it for its own command. With an
119
+ * `args` array it becomes the exec path: `command` is spawned directly with those args.
120
+ * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
121
+ * still running at session end can be reaped (Claude kills async hooks at teardown). */
122
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
140
123
  new Promise((resolve) => {
141
124
  // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
142
125
  // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
@@ -150,6 +133,11 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, {
150
133
  // detection cannot see the captured terminal.
151
134
  const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
152
135
  if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
136
+ // Claude: "Claude Code sets this to its own process ID in the subprocesses it
137
+ // spawns: Bash and PowerShell tool commands and hook commands." Set unconditionally,
138
+ // since every hook child qualifies.
139
+ env.CLAUDE_PID = String(process.pid)
140
+ if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
153
141
  // Claude: "All three are exported as environment variables to hook processes and to
154
142
  // MCP and LSP server subprocesses", so a plugin script can read them rather than
155
143
  // depend on inline substitution. The data directory is "created on first reference",
@@ -36,7 +36,7 @@ export function setAgentRunner(fn: AgentRunner | undefined): void {
36
36
  runner = fn
37
37
  }
38
38
 
39
- /** Whether a runner is registered, so agent hooks can be reported as runnable. */
39
+ /** Test seam: whether a runner is registered. */
40
40
  export function hasAgentRunner(): boolean {
41
41
  return runner !== undefined
42
42
  }
@@ -11,8 +11,7 @@
11
11
  */
12
12
 
13
13
  import { hasSubstitution, splitSegments } from './shell-split.js'
14
-
15
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
14
+ import { escapeRegExp } from './values.js'
16
15
 
17
16
  /**
18
17
  * One rule against one command segment, per Claude's permission table:
@@ -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
@@ -72,6 +72,14 @@ function storeFileFor(serverName: string, endpoint?: string): string {
72
72
  return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
73
73
  }
74
74
 
75
+ /** MCP_OAUTH_CALLBACK_PORT parsed, or undefined when unset or not a plain integer. */
76
+ function envCallbackPort(): number | undefined {
77
+ const raw = process.env.MCP_OAUTH_CALLBACK_PORT
78
+ if (raw === undefined || raw.trim() === '') return undefined
79
+ const parsed = Number(raw)
80
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
81
+ }
82
+
75
83
  export class FileOAuthProvider implements OAuthClientProvider {
76
84
  private readonly storePath: string
77
85
  private readonly data: StoredAuth
@@ -107,15 +115,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
107
115
  }
108
116
 
109
117
  /** The configured callbackPort alone, absent when only a remembered port exists.
110
- * The caller needs the two apart: a configured port is a hard requirement. */
118
+ * The caller needs the two apart: a configured port is a hard requirement. Falls
119
+ * back to MCP_OAUTH_CALLBACK_PORT, Claude's "alternative to --callback-port when
120
+ * adding an MCP server with pre-configured credentials"; pi-code has no `mcp add`
121
+ * command, so the env var applies as a default for any server naming no port of
122
+ * its own rather than only ones added that way. */
111
123
  configuredRedirectPort(): number | undefined {
112
- return this.oauth?.callbackPort
124
+ return this.oauth?.callbackPort ?? envCallbackPort()
113
125
  }
114
126
 
115
127
  /** The configured callbackPort (Claude: for pre-registered redirect URIs), else
116
128
  * the port a prior login registered, so a re-login can bind the same one. */
117
129
  savedRedirectPort(): number | undefined {
118
- return this.oauth?.callbackPort ?? this.data.redirectPort
130
+ return this.oauth?.callbackPort ?? envCallbackPort() ?? this.data.redirectPort
119
131
  }
120
132
 
121
133
  /** Record the loopback port the callback server actually bound; the redirect
@@ -236,7 +248,17 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number, expected
236
248
  // abandoned or resolved out of band, the event loop can still drain.
237
249
  timer.unref?.()
238
250
  server.on('request', (request, response) => {
239
- 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
+ }
240
262
  // Only the redirect path settles the login. A stray request (a favicon fetch, a
241
263
  // local port scan, or a forged redirect from another process or an open web page)
242
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
@@ -96,10 +95,29 @@ function bracketEnd(pattern: string, start: number): number {
96
95
 
97
96
  /** A bracket expression body as a regex character class, escaping regex-relevant
98
97
  * characters while keeping `-` ranges; a leading `!` (or `^`) negates. */
99
- function bracketClass(body: string): string {
98
+ function bracketClass(body: string): string | null {
100
99
  const negated = body.startsWith('!') || body.startsWith('^')
101
100
  const members = (negated ? body.slice(1) : body).replace(/[\\\]^]/g, (ch) => `\\${ch}`)
102
- return `[${negated ? '^' : ''}${members}]`
101
+ const source = `[${negated ? '^' : ''}${members}]`
102
+ // A range whose endpoints descend, `["- ]` for instance, is not a character class
103
+ // JavaScript will build: RegExp throws "Range out of order in character class". The
104
+ // `-` cannot simply be escaped, since `[a-z]` is the whole point of the syntax, so the
105
+ // class is validated by construction and an unbuildable one is treated exactly as an
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.
109
+ return isBuildableClass(source) ? source : null
110
+ }
111
+
112
+ /** Whether JavaScript will build this character class. Asking RegExp is the only
113
+ * faithful test: the invalid forms are its rules, not ones worth re-deriving here. */
114
+ function isBuildableClass(source: string): boolean {
115
+ try {
116
+ new RegExp(source)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
103
121
  }
104
122
 
105
123
  /** A `*` run starting at `i`: a double star followed by a slash spans whole
@@ -114,39 +132,33 @@ function translateStar(pattern: string, i: number): { source: string; next: numb
114
132
  return { source: '[^/]*', next: i + 1 }
115
133
  }
116
134
 
135
+ /** The regex source for the construct at `i` and the index after it, or null when the
136
+ * pattern is invalid there and so matches nothing. */
137
+ function translateAt(pattern: string, i: number): { source: string; next: number } | null {
138
+ const ch = pattern[i]
139
+ // Claude: to match a literal bracket, escape it; the escape consumes both chars.
140
+ if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) return { source: escapeRegExp(pattern[i + 1]), next: i + 2 }
141
+ // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be read
142
+ // as one, or a body that is not a buildable class, makes the pattern invalid.
143
+ if (ch === '[') {
144
+ const end = bracketEnd(pattern, i)
145
+ if (end === -1) return null
146
+ const cls = bracketClass(pattern.slice(i + 1, end))
147
+ return cls === null ? null : { source: cls, next: end + 1 }
148
+ }
149
+ if (ch === '*') return translateStar(pattern, i)
150
+ if (ch === '?') return { source: '[^/]', next: i + 1 }
151
+ return { source: escapeRegExp(ch), next: i + 1 }
152
+ }
153
+
117
154
  function translateGlob(pattern: string): string | null {
118
155
  let out = ''
119
156
  let i = 0
120
157
  while (i < pattern.length) {
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
- }
137
- if (ch === '*') {
138
- const star = translateStar(pattern, i)
139
- out += star.source
140
- i = star.next
141
- continue
142
- }
143
- if (ch === '?') {
144
- out += '[^/]'
145
- i += 1
146
- continue
147
- }
148
- out += escapeRegExp(ch)
149
- i += 1
158
+ const step = translateAt(pattern, i)
159
+ if (step === null) return null
160
+ out += step.source
161
+ i = step.next
150
162
  }
151
163
  return out
152
164
  }
@@ -183,9 +195,7 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
183
195
  return path.join(anchors.cwd, rel)
184
196
  }
185
197
 
186
- /** One rule glob precompiled for repeated matching: its anchored regex, and whether
187
- * it applies to the basename (a slashless pattern, gitignore-style) or the full
188
- * root-relative path. */
198
+ /** One rule glob precompiled for repeated matching: its anchored regex. */
189
199
  export interface CompiledGlob {
190
200
  regex: RegExp
191
201
  }
@@ -199,13 +209,13 @@ export function globCompileStats(): { compiled: number; evaluated: number } {
199
209
  return { compiled: globsCompiled, evaluated: globsEvaluated }
200
210
  }
201
211
 
202
- /** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
203
- * pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
204
- * slash scopes to the directory's contents, and blank entries drop out. */
205
212
  /** Claude's shared list budget: rule patterns past ~1000 compiled entries are
206
213
  * ignored rather than compiled without bound. */
207
214
  const LIST_PATTERN_BUDGET = 1000
208
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. */
209
219
  export function compileGlobs(globs: string[]): CompiledGlob[] {
210
220
  const compiled: CompiledGlob[] = []
211
221
  for (const raw of globs) {
@@ -231,8 +241,6 @@ export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): bo
231
241
  return globs.some((glob) => glob.regex.test(posix))
232
242
  }
233
243
 
234
- /** Whether the accessed file matches at least one rule. No rules means no match:
235
- * a granted-but-scoped tool with an empty scope set stays blocked, never open. */
236
244
  /** Both comparison sides in posix form. On Windows, resolve stamps the drive on
237
245
  * the target while join-built rules stay drive-less, and backslash separators
238
246
  * collide with glob syntax, so unnormalized rules could never match. */
@@ -260,6 +268,8 @@ export function stripRuleDrive(rule: string, windows: boolean): string {
260
268
  return rule.replace(/^([A-Za-z]):\//, '/').replace(/^\/[A-Za-z]\//, '/')
261
269
  }
262
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. */
263
273
  export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
264
274
  const target = toPosix(path.resolve(anchors.cwd, filePath))
265
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