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.
- package/README.md +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +46 -24
- package/extensions/git-checkpoint.ts +141 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +14 -10
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +17 -29
- 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 +28 -6
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +51 -41
- 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 +19 -6
- 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 +78 -19
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +12 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +142 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +11 -6
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +19 -4
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +34 -20
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|