pi-code 1.0.63 → 1.0.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +3 -3
- package/extensions/env-settings.ts +5 -2
- package/extensions/git-checkpoint.ts +137 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +22 -11
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +7 -28
- package/extensions/internal/agent-run.ts +1 -1
- package/extensions/internal/bash-rules.ts +1 -2
- package/extensions/internal/claude-tool-names.ts +4 -7
- package/extensions/internal/command-file.ts +2 -3
- package/extensions/internal/external-imports.ts +4 -5
- package/extensions/internal/goal-evaluator.ts +4 -3
- package/extensions/internal/instruction-events.ts +5 -5
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +13 -3
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +10 -13
- package/extensions/internal/plugins.ts +5 -5
- package/extensions/internal/process-tree.ts +39 -0
- package/extensions/internal/project-approval.ts +7 -7
- package/extensions/internal/project-root.ts +13 -13
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/settings-watch.ts +3 -1
- package/extensions/internal/shell-resolve.ts +3 -1
- package/extensions/internal/tool-target.ts +4 -5
- package/extensions/internal/values.ts +20 -4
- package/extensions/mcp/config.ts +11 -4
- package/extensions/mcp/index.ts +40 -4
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +2 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +2 -7
- package/extensions/skills.ts +5 -5
- package/extensions/status-line.ts +4 -0
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +2 -2
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +3 -14
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- package/package.json +7 -7
|
@@ -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. */
|
|
@@ -96,30 +92,9 @@ const MAX_HOOK_OUTPUT = 1_000_000
|
|
|
96
92
|
/** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
|
|
97
93
|
const TIMEOUT_EXIT_CODE = 124
|
|
98
94
|
|
|
99
|
-
/**
|
|
100
|
-
* Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
|
|
101
|
-
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
102
|
-
*/
|
|
95
|
+
/** Kill the shell and everything it spawned; see internal/process-tree. */
|
|
103
96
|
function killTree(child: ChildProcess): void {
|
|
104
|
-
|
|
105
|
-
// Windows has no process groups: taskkill /T ends the shell's whole tree. By
|
|
106
|
-
// absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
|
|
107
|
-
// cannot start, the direct kill is all that is left.
|
|
108
|
-
const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
|
|
109
|
-
if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
|
|
110
|
-
else child.kill('SIGKILL')
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
115
|
-
if (child.pid) {
|
|
116
|
-
process.kill(-child.pid, 'SIGKILL')
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
} catch {
|
|
120
|
-
// Group already reaped, or the platform refused it; fall through to the direct kill.
|
|
121
|
-
}
|
|
122
|
-
child.kill('SIGKILL')
|
|
97
|
+
killProcessTree(child, 'SIGKILL')
|
|
123
98
|
}
|
|
124
99
|
|
|
125
100
|
/** Create the plugin data directory the moment its path is handed to a child. Claude
|
|
@@ -140,6 +115,10 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
|
|
|
140
115
|
return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
|
|
141
116
|
}
|
|
142
117
|
|
|
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). */
|
|
143
122
|
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
|
|
144
123
|
new Promise((resolve) => {
|
|
145
124
|
// /bin/sh by absolute path off Windows, so the shell can't be resolved through an
|
|
@@ -36,7 +36,7 @@ export function setAgentRunner(fn: AgentRunner | undefined): void {
|
|
|
36
36
|
runner = fn
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Claude's vocabulary
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
242
|
-
const
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// whose directory is a strict ancestor of the
|
|
55
|
-
// memory. The home directory itself stays User: a
|
|
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
|
-
//
|
|
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
|
|
65
|
-
// (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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
|
|
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
|
-
*
|
|
20
|
-
* project
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
+
}
|