pi-code 1.0.61 → 1.0.62
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 +2 -0
- package/extensions/commands.ts +45 -11
- package/extensions/env-settings.ts +19 -1
- package/extensions/hooks/claude-tools.ts +9 -7
- package/extensions/hooks/config.ts +20 -3
- package/extensions/hooks/index.ts +14 -3
- package/extensions/hooks/matcher.ts +36 -8
- package/extensions/hooks/runners.ts +36 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +16 -3
- package/extensions/internal/path-rules.ts +18 -1
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +11 -5
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +19 -3
- package/extensions/skills.ts +20 -2
- package/extensions/status-line.ts +4 -2
- package/extensions/subagent/index.ts +4 -2
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
|
@@ -18,6 +18,8 @@ import * as path from 'node:path'
|
|
|
18
18
|
|
|
19
19
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
20
20
|
|
|
21
|
+
import { CLAUDE_TOOL_MAP } from './claude-tool-names.js'
|
|
22
|
+
|
|
21
23
|
/** The pi file tools a Claude path rule can govern. */
|
|
22
24
|
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
23
25
|
|
|
@@ -27,6 +29,12 @@ export interface ParsedCommand {
|
|
|
27
29
|
allowedTools?: string[]
|
|
28
30
|
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
|
|
29
31
|
bashRules?: string[]
|
|
32
|
+
/** Claude `WebFetch(domain:...)` specifiers, on the same unscoped-wins rule. */
|
|
33
|
+
domainRules?: string[]
|
|
34
|
+
/** Claude `Agent(...)`/`Task(...)` agent names, on the same unscoped-wins rule. */
|
|
35
|
+
agentRules?: string[]
|
|
36
|
+
/** Claude `Skill(...)` name patterns, on the same unscoped-wins rule. */
|
|
37
|
+
skillRules?: string[]
|
|
30
38
|
/** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
|
|
31
39
|
pathRules?: Partial<Record<PathRuleTool, string[]>>
|
|
32
40
|
/** Names from the `arguments:` frontmatter list, mapped to positions in order. */
|
|
@@ -58,34 +66,6 @@ export interface DiscoveredCommand {
|
|
|
58
66
|
plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
/** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
|
|
62
|
-
* `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
|
|
63
|
-
* that name the grant was silently dropped when the list was intersected with the
|
|
64
|
-
* active tools. Shared with the subagent's own frontmatter parsing. */
|
|
65
|
-
const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
66
|
-
read: 'read',
|
|
67
|
-
write: 'write',
|
|
68
|
-
edit: 'edit',
|
|
69
|
-
bash: 'bash',
|
|
70
|
-
grep: 'grep',
|
|
71
|
-
glob: 'find',
|
|
72
|
-
ls: 'ls',
|
|
73
|
-
// Claude's names for the tools this package registers itself. Without these a
|
|
74
|
-
// perfectly ordinary `allowed-tools: WebFetch, WebSearch` matched no pi tool and
|
|
75
|
-
// the intersection left the turn with nothing.
|
|
76
|
-
webfetch: 'web_fetch',
|
|
77
|
-
websearch: 'web_search',
|
|
78
|
-
todowrite: 'todo',
|
|
79
|
-
todoread: 'todo',
|
|
80
|
-
task: 'subagent',
|
|
81
|
-
askuserquestion: 'question',
|
|
82
|
-
exitplanmode: 'plan_mode_complete',
|
|
83
|
-
// Claude's name for the tool this package registers so the model can run user slash
|
|
84
|
-
// commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
|
|
85
|
-
// could neither keep nor drop the tool.
|
|
86
|
-
slashcommand: 'slash_command',
|
|
87
|
-
}
|
|
88
|
-
|
|
89
69
|
/**
|
|
90
70
|
* The pi tool name for one grant entry, scope and all: `Bash(git add:*)` is `bash`.
|
|
91
71
|
* Keeping the scope in the name matched nothing when the list was intersected with
|
|
@@ -135,6 +115,14 @@ export interface ToolGrants {
|
|
|
135
115
|
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped:
|
|
136
116
|
* an unscoped `Bash` entry is the wider grant and wins over its scoped siblings. */
|
|
137
117
|
bashRules?: string[]
|
|
118
|
+
/** Claude `WebFetch(domain:host)` specifiers, same unscoped-wins rule as bash.
|
|
119
|
+
* Dropping them granted unrestricted web_fetch to a command that asked for one host. */
|
|
120
|
+
domainRules?: string[]
|
|
121
|
+
/** Claude `Agent(AgentName)` names from `Agent(...)` or the legacy `Task(...)`,
|
|
122
|
+
* same unscoped-wins rule. Dropping them granted the whole subagent tool. */
|
|
123
|
+
agentRules?: string[]
|
|
124
|
+
/** Claude `Skill(name)` / `Skill(name *)` specifiers, same unscoped-wins rule. */
|
|
125
|
+
skillRules?: string[]
|
|
138
126
|
/** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
|
|
139
127
|
* Edit scopes govern writes too, as Claude documents; Write scopes are honored
|
|
140
128
|
* rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
|
|
@@ -150,18 +138,32 @@ const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
|
|
|
150
138
|
write: ['write'],
|
|
151
139
|
}
|
|
152
140
|
|
|
141
|
+
/** pi tools whose Claude specifier is a scalar argument scope rather than a path
|
|
142
|
+
* rule: `Bash(cmd)`, `WebFetch(domain:host)` and `Agent(AgentName)`. One list, so a
|
|
143
|
+
* tool cannot be granted a scope here and quietly miss the unscoped-wins rule. */
|
|
144
|
+
const ARG_RULE_TOOLS = ['bash', 'web_fetch', 'subagent', 'slash_command'] as const
|
|
145
|
+
type ArgRuleTool = (typeof ARG_RULE_TOOLS)[number]
|
|
146
|
+
|
|
147
|
+
const isArgRuleTool = (name: string): name is ArgRuleTool => (ARG_RULE_TOOLS as readonly string[]).includes(name)
|
|
148
|
+
|
|
153
149
|
/** The tools, scopes, and path rules accumulated while scanning one grant list. */
|
|
154
150
|
interface GrantAccumulator {
|
|
155
151
|
tools: string[]
|
|
156
152
|
scopedEntries: string[]
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
argScopes: Record<ArgRuleTool, string[]>
|
|
154
|
+
argUnscoped: Set<ArgRuleTool>
|
|
159
155
|
pathScopes: Record<PathRuleTool, string[]>
|
|
160
156
|
pathUnscoped: Set<PathRuleTool>
|
|
161
157
|
}
|
|
162
158
|
|
|
163
159
|
function createGrantAccumulator(): GrantAccumulator {
|
|
164
|
-
return { tools: [], scopedEntries: [],
|
|
160
|
+
return { tools: [], scopedEntries: [], argScopes: { bash: [], web_fetch: [], subagent: [], slash_command: [] }, argUnscoped: new Set(), pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The scopes for one argument-ruled tool, or undefined when it has none or when an
|
|
164
|
+
* unscoped grant for it makes it wide. The wider grant wins, as it does for bash. */
|
|
165
|
+
function argRules(acc: GrantAccumulator, tool: ArgRuleTool): string[] | undefined {
|
|
166
|
+
return !acc.argUnscoped.has(tool) && acc.argScopes[tool].length > 0 ? acc.argScopes[tool] : undefined
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
/** Coerce a raw grant value to its string entries: a YAML list stays a list, a
|
|
@@ -186,7 +188,7 @@ function addGrantEntry(acc: GrantAccumulator, item: string): void {
|
|
|
186
188
|
if (!acc.tools.includes(name)) acc.tools.push(name)
|
|
187
189
|
const open = entry.indexOf('(')
|
|
188
190
|
if (open === -1) {
|
|
189
|
-
if (name
|
|
191
|
+
if (isArgRuleTool(name)) acc.argUnscoped.add(name)
|
|
190
192
|
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
|
|
191
193
|
return
|
|
192
194
|
}
|
|
@@ -195,7 +197,7 @@ function addGrantEntry(acc: GrantAccumulator, item: string): void {
|
|
|
195
197
|
// An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
|
|
196
198
|
// the unscoped grant it explicitly is not: it is recorded so the tool stays
|
|
197
199
|
// restricted, and the matchers treat an empty rule as matching no input.
|
|
198
|
-
if (name
|
|
200
|
+
if (isArgRuleTool(name)) acc.argScopes[name].push(scope)
|
|
199
201
|
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
|
|
200
202
|
}
|
|
201
203
|
|
|
@@ -217,10 +219,14 @@ function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
|
|
|
217
219
|
*
|
|
218
220
|
* Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
|
|
219
221
|
* pi's active-tool list is per tool, with no argument dimension, so the base tool is
|
|
220
|
-
* granted and the scope is kept
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
222
|
+
* granted and the scope is kept for commands.ts to enforce at tool_call time. Every
|
|
223
|
+
* specifier Claude documents for an allow rule is kept: `Bash(cmd)`, the Read/Edit
|
|
224
|
+
* path rules, `WebFetch(domain:host)` and `Agent(AgentName)`. The subagent's own
|
|
225
|
+
* frontmatter parsing still rejects a scoped grant outright, since it has no
|
|
226
|
+
* call-time seam to enforce one in.
|
|
227
|
+
*
|
|
228
|
+
* Claude's `Tool(param:value)` form is not among them: the permissions reference
|
|
229
|
+
* confines it to deny and ask rules, and `allowed-tools` is an allow surface.
|
|
224
230
|
*/
|
|
225
231
|
export function parseToolGrants(raw: unknown): ToolGrants | undefined {
|
|
226
232
|
const items = coerceGrantItems(raw)
|
|
@@ -230,7 +236,10 @@ export function parseToolGrants(raw: unknown): ToolGrants | undefined {
|
|
|
230
236
|
return {
|
|
231
237
|
tools: acc.tools,
|
|
232
238
|
scopedEntries: acc.scopedEntries,
|
|
233
|
-
bashRules:
|
|
239
|
+
bashRules: argRules(acc, 'bash'),
|
|
240
|
+
domainRules: argRules(acc, 'web_fetch'),
|
|
241
|
+
agentRules: argRules(acc, 'subagent'),
|
|
242
|
+
skillRules: argRules(acc, 'slash_command'),
|
|
234
243
|
pathRules: buildPathRules(acc),
|
|
235
244
|
}
|
|
236
245
|
}
|
|
@@ -293,6 +302,9 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
293
302
|
argumentHint: hint(frontmatter['argument-hint']) || undefined,
|
|
294
303
|
allowedTools: grants?.tools,
|
|
295
304
|
bashRules: grants?.bashRules,
|
|
305
|
+
domainRules: grants?.domainRules,
|
|
306
|
+
agentRules: grants?.agentRules,
|
|
307
|
+
skillRules: grants?.skillRules,
|
|
296
308
|
pathRules: grants?.pathRules,
|
|
297
309
|
argumentNames: parseArgumentNames(frontmatter.arguments),
|
|
298
310
|
// A scope on a disallow entry only denies more than asked, so the drop is safe.
|
|
@@ -18,6 +18,7 @@ import * as path from 'node:path'
|
|
|
18
18
|
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
19
19
|
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
|
20
20
|
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
|
|
21
|
+
import { errorMessage } from './values.js'
|
|
21
22
|
|
|
22
23
|
interface StoredAuth {
|
|
23
24
|
client?: OAuthClientInformationMixed
|
|
@@ -105,6 +106,12 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
105
106
|
fs.writeFileSync(this.storePath, JSON.stringify(this.data), { mode: 0o600 })
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** 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. */
|
|
111
|
+
configuredRedirectPort(): number | undefined {
|
|
112
|
+
return this.oauth?.callbackPort
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
/** The configured callbackPort (Claude: for pre-registered redirect URIs), else
|
|
109
116
|
* the port a prior login registered, so a re-login can bind the same one. */
|
|
110
117
|
savedRedirectPort(): number | undefined {
|
|
@@ -193,13 +200,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
193
200
|
/** A one-shot loopback listener for the authorization redirect. Loopback redirect
|
|
194
201
|
* URIs are the RFC 8252 pattern for native apps. A preferred port (from a prior
|
|
195
202
|
* login) is tried first so a re-login keeps the registered redirect_uri; if it is
|
|
196
|
-
* taken, an ephemeral port is used.
|
|
197
|
-
|
|
203
|
+
* taken, an ephemeral port is used.
|
|
204
|
+
*
|
|
205
|
+
* `portRequired` marks the port as configured rather than remembered. A configured
|
|
206
|
+
* `oauth.callbackPort` names the redirect_uri the IdP has registered, so quietly
|
|
207
|
+
* binding a different one sends the user to an opaque redirect_uri mismatch at the
|
|
208
|
+
* IdP; the bind failure is reported here instead, where it can name the real cause. */
|
|
209
|
+
export async function startCallbackServer(preferredPort?: number, portRequired = false): Promise<{ server: http.Server; port: number }> {
|
|
198
210
|
const server = http.createServer()
|
|
199
211
|
const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
|
|
200
212
|
try {
|
|
201
213
|
await listen(preferredPort ?? 0, '127.0.0.1')
|
|
202
|
-
} catch {
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (portRequired) throw new Error(`oauth.callbackPort ${preferredPort} is in use, so the registered redirect URI cannot be served: free that port or change oauth.callbackPort (${errorMessage(error)})`)
|
|
203
216
|
await listen(0, '127.0.0.1')
|
|
204
217
|
}
|
|
205
218
|
const port = (server.address() as { port: number }).port
|
|
@@ -243,6 +243,23 @@ const toPosix = (target: string): string => {
|
|
|
243
243
|
return withSlashes.replace(/^\/?[A-Za-z]:\//, '/')
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Drop a drive from a RESOLVED rule so it can meet a target that toPosix has already
|
|
248
|
+
* stripped. Claude documents `//path` as an absolute path from the filesystem root,
|
|
249
|
+
* and on Windows its own example names the drive as the first segment (`//c/` then a
|
|
250
|
+
* recursive glob), where `c` is the drive; that resolved to `/c/...` while every target
|
|
251
|
+
* resolved to `/...`, so the rule could never match anything.
|
|
252
|
+
*
|
|
253
|
+
* Windows only, and that is the whole point of the flag: on POSIX `/c/foo` is an
|
|
254
|
+
* ordinary absolute path and stripping its first segment would widen the rule to
|
|
255
|
+
* everything under the root. Exported and platform-parameterized rather than reading
|
|
256
|
+
* process.platform inline, so both branches are assertable from either host.
|
|
257
|
+
*/
|
|
258
|
+
export function stripRuleDrive(rule: string, windows: boolean): string {
|
|
259
|
+
if (!windows) return rule
|
|
260
|
+
return rule.replace(/^([A-Za-z]):\//, '/').replace(/^\/[A-Za-z]\//, '/')
|
|
261
|
+
}
|
|
262
|
+
|
|
246
263
|
export function matchesPathRules(filePath: string, rules: string[], anchors: PathAnchors): boolean {
|
|
247
264
|
const target = toPosix(path.resolve(anchors.cwd, filePath))
|
|
248
265
|
return rules.some((rule) => {
|
|
@@ -250,7 +267,7 @@ export function matchesPathRules(filePath: string, rules: string[], anchors: Pat
|
|
|
250
267
|
// An empty specifier (`Read()`) matches nothing, so the tool stays blocked
|
|
251
268
|
// rather than falling open, mirroring `Bash()`.
|
|
252
269
|
if (trimmed === '') return false
|
|
253
|
-
const resolved = toPosix(resolveRule(trimmed, anchors))
|
|
270
|
+
const resolved = stripRuleDrive(toPosix(resolveRule(trimmed, anchors)), path.sep === '\\')
|
|
254
271
|
return new RegExp(`^${globToRegExpSource(resolved)}$`).test(target)
|
|
255
272
|
})
|
|
256
273
|
}
|
|
@@ -215,6 +215,17 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
|
|
|
215
215
|
return plugins
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The plugins a managed `enabledPlugins` entry force-enables. Claude exempts their
|
|
219
|
+
* hooks from `allowManagedHooksOnly`: an administrator who turned a plugin on meant its
|
|
220
|
+
* hooks to run. Keys are matched the way pluginEnabled matches them, by the qualified
|
|
221
|
+
* `name@marketplace` or the bare directory name, so the two cannot drift apart. */
|
|
222
|
+
export function managedForceEnabled(plugins: InstalledPlugin[]): InstalledPlugin[] {
|
|
223
|
+
const managedEntry = readManagedSettings().enabledPlugins
|
|
224
|
+
if (managedEntry === null || typeof managedEntry !== 'object') return []
|
|
225
|
+
const entries = managedEntry as Record<string, unknown>
|
|
226
|
+
return plugins.filter((plugin) => Object.entries(entries).some(([key, value]) => value === true && (key === plugin.name || key.startsWith(`${plugin.name}@`))))
|
|
227
|
+
}
|
|
228
|
+
|
|
218
229
|
/** The plugin's effective enablement per Claude's precedence: a managed
|
|
219
230
|
* enabledPlugins entry force-enables or blocks, then the user's setting, then the
|
|
220
231
|
* manifest's defaultEnabled, which defaults to true ("starts in an enabled state
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude's argument scopes for the two tools whose specifier is neither a bash
|
|
3
|
+
* command nor a file path: `WebFetch(domain:host)` and `Agent(AgentName)`.
|
|
4
|
+
*
|
|
5
|
+
* pi's active-tool set has no argument dimension, so commands.ts grants the base
|
|
6
|
+
* tool and checks each call here, the way it already does for `Bash(...)` scopes.
|
|
7
|
+
* Both matchers fail closed: a scope this module cannot interpret matches nothing
|
|
8
|
+
* rather than reading as the unscoped grant the author did not write.
|
|
9
|
+
*
|
|
10
|
+
* Claude's parameter form `Tool(param:value)` is deliberately absent. The
|
|
11
|
+
* permissions reference restricts it to deny and ask rules ("An allow rule for one
|
|
12
|
+
* parameter value wouldn't establish that the call is safe overall, so allow rules
|
|
13
|
+
* continue to use each tool's own specifier syntax"), and every scope reaching this
|
|
14
|
+
* module comes from an allow surface: a command's `allowed-tools`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Regex specials except `*`, which carries the rule's own wildcard meaning. */
|
|
18
|
+
const escapeExceptStar = (text: string): string => text.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
19
|
+
|
|
20
|
+
/** A hostname pattern segment: `*` matches any text that does not cross a dot. */
|
|
21
|
+
const hostPattern = (pattern: string): string => escapeExceptStar(pattern).replaceAll('*', '[^.]*')
|
|
22
|
+
|
|
23
|
+
/** Lowercased, with the trailing `.` the reference strips from both sides removed. */
|
|
24
|
+
const canonicalHost = (host: string): string => host.trim().toLowerCase().replace(/\.$/, '')
|
|
25
|
+
|
|
26
|
+
/** The hostname of a fetch target, or '' when the url does not parse. */
|
|
27
|
+
function hostnameOf(url: string): string {
|
|
28
|
+
try {
|
|
29
|
+
return canonicalHost(new URL(url).hostname)
|
|
30
|
+
} catch {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The host pattern of one `domain:` scope, or undefined for any other spelling.
|
|
36
|
+
* WebFetch has exactly one documented specifier syntax, so an unrecognized scope is
|
|
37
|
+
* not a wider grant, it is a rule that matches nothing. */
|
|
38
|
+
function domainPattern(rule: string): string | undefined {
|
|
39
|
+
const trimmed = rule.trim()
|
|
40
|
+
const colon = trimmed.indexOf(':')
|
|
41
|
+
if (colon === -1 || trimmed.slice(0, colon).trim().toLowerCase() !== 'domain') return undefined
|
|
42
|
+
const pattern = canonicalHost(trimmed.slice(colon + 1))
|
|
43
|
+
return pattern === '' ? undefined : pattern
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchesDomainRule(host: string, rule: string): boolean {
|
|
47
|
+
const pattern = domainPattern(rule)
|
|
48
|
+
if (pattern === undefined) return false
|
|
49
|
+
if (pattern === '*') return true
|
|
50
|
+
// A leading `*.` is the one wildcard that crosses dots: it stands for one or more
|
|
51
|
+
// whole labels, so it covers `a.b.example.com` while leaving the apex unmatched.
|
|
52
|
+
if (pattern.startsWith('*.')) return new RegExp(String.raw`^(?:[^.]+\.)+${hostPattern(pattern.slice(2))}$`).test(host)
|
|
53
|
+
return new RegExp(`^${hostPattern(pattern)}$`).test(host)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Claude: "WebFetch rules use a `domain:` prefix and match against the hostname of
|
|
58
|
+
* the requested URL. Matching is case-insensitive, supports `*` wildcards, and strips
|
|
59
|
+
* a trailing `.` from both the rule and the hostname."
|
|
60
|
+
*/
|
|
61
|
+
export function matchesDomainRules(url: string, rules: string[]): boolean {
|
|
62
|
+
const host = hostnameOf(url)
|
|
63
|
+
if (host === '') return false
|
|
64
|
+
return rules.some((rule) => matchesDomainRule(host, rule))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Claude: "Permission syntax: `Skill(name)` for exact match, `Skill(name *)` for
|
|
69
|
+
* prefix match with any arguments."
|
|
70
|
+
*
|
|
71
|
+
* The invocation is the skill name and its arguments as one string, the shape pi's
|
|
72
|
+
* `slash_command` tool takes; a leading `/` is optional there, so it is stripped
|
|
73
|
+
* before matching. Only the two documented forms are interpreted. A rule spelled any
|
|
74
|
+
* other way, `commit:*` included, matches nothing rather than widening the grant.
|
|
75
|
+
*/
|
|
76
|
+
export function matchesSkillRules(invocation: string, rules: string[]): boolean {
|
|
77
|
+
const call = invocation.trim().replace(/^\//, '').trim()
|
|
78
|
+
if (call === '') return false
|
|
79
|
+
return rules.some((raw) => {
|
|
80
|
+
const rule = raw.trim()
|
|
81
|
+
if (rule === '') return false
|
|
82
|
+
if (!rule.endsWith(' *')) return rule === call
|
|
83
|
+
// The space before the trailing `*` is part of the rule, so `review-pr *` covers
|
|
84
|
+
// `review-pr` and `review-pr 123` but never the longer name `review-pretend`.
|
|
85
|
+
const prefix = rule.slice(0, -2).trimEnd()
|
|
86
|
+
return prefix !== '' && (call === prefix || call.startsWith(`${prefix} `))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `agent` of one call or task entry, when it has one. */
|
|
91
|
+
const agentNameOf = (value: unknown): string | undefined => {
|
|
92
|
+
const record = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
|
|
93
|
+
return typeof record?.agent === 'string' ? record.agent : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every agent name one subagent call names, across all three modes: `agent` for
|
|
97
|
+
* single, and the `agent` of each entry in `tasks` (parallel) or `chain` (sequential).
|
|
98
|
+
* One collector, so a rule checked against single mode cannot be quietly skipped for
|
|
99
|
+
* the two modes that carry their names in an array. */
|
|
100
|
+
export function agentNamesIn(input: unknown): string[] {
|
|
101
|
+
const raw = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
|
102
|
+
const listed = ['tasks', 'chain'].flatMap((key) => (Array.isArray(raw[key]) ? (raw[key] as unknown[]) : []))
|
|
103
|
+
return [input, ...listed].map(agentNameOf).filter((name): name is string => name !== undefined)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Claude: "Use `Agent(AgentName)` rules to control which subagents Claude can use."
|
|
108
|
+
*
|
|
109
|
+
* Every agent the call names must match, not just the first. A subagent call carries
|
|
110
|
+
* names in `agent`, `tasks[].agent` and `chain[].agent`; gating one field would let
|
|
111
|
+
* parallel or chain mode route around the rule. A call naming no agent cannot be
|
|
112
|
+
* checked against the scope, so it fails closed too.
|
|
113
|
+
*/
|
|
114
|
+
export function matchesAgentRules(names: string[], rules: string[]): boolean {
|
|
115
|
+
if (names.length === 0) return false
|
|
116
|
+
return names.every((name) => rules.some((rule) => rule.trim() !== '' && rule.trim() === name.trim()))
|
|
117
|
+
}
|
|
@@ -52,6 +52,13 @@ export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
|
52
52
|
// (204/205/304) and for status 0. That throw fires here, off the Promise
|
|
53
53
|
// executor, so without this guard it escapes as an uncaughtException and pi
|
|
54
54
|
// exits. Give those statuses a null body; reject anything else that throws.
|
|
55
|
+
//
|
|
56
|
+
// The catch below has no test and cannot get one through this function: the
|
|
57
|
+
// only two throw sources are the null-body statuses, which the line under this
|
|
58
|
+
// comment handles, and a status outside 200-599, which never reaches this
|
|
59
|
+
// callback at all (node routes 1xx to the `information` event and rejects a
|
|
60
|
+
// malformed status line in the parser). It stays as depth, not dead code, but
|
|
61
|
+
// do not chase its coverage with a test that reaches it some other way.
|
|
55
62
|
const body = NULL_BODY_STATUSES.has(status) ? null : (Readable.toWeb(res) as ReadableStream<Uint8Array>)
|
|
56
63
|
resolve(new Response(body, { status, headers }))
|
|
57
64
|
} catch (err) {
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface StdioServerConfig {
|
|
|
27
27
|
baseName?: string
|
|
28
28
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
29
29
|
pluginRoot?: string
|
|
30
|
+
/** ${CLAUDE_PLUGIN_DATA} for a plugin's server, exported alongside the root. */
|
|
31
|
+
pluginDataDir?: string
|
|
30
32
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
31
33
|
projectScope?: boolean
|
|
32
34
|
}
|
|
@@ -52,6 +54,8 @@ export interface HttpServerConfig {
|
|
|
52
54
|
baseName?: string
|
|
53
55
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
54
56
|
pluginRoot?: string
|
|
57
|
+
/** ${CLAUDE_PLUGIN_DATA} for a plugin's server, exported alongside the root. */
|
|
58
|
+
pluginDataDir?: string
|
|
55
59
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
56
60
|
projectScope?: boolean
|
|
57
61
|
}
|
|
@@ -225,7 +229,7 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
|
|
|
225
229
|
// plugin:<plugin-name>:<server-name>", which is what an mcp_tool hook names and what
|
|
226
230
|
// keeps a same-named user server from replacing a plugin's. The tool alias keeps its
|
|
227
231
|
// own flat spelling, mcp__plugin_<plugin>_<server>__<tool>.
|
|
228
|
-
if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root }
|
|
232
|
+
if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root, pluginDataDir: plugin.dataDir }
|
|
229
233
|
}
|
|
230
234
|
}
|
|
231
235
|
return servers
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -85,6 +85,10 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
|
|
|
85
85
|
|
|
86
86
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
87
87
|
const clients = new Map<string, Client>()
|
|
88
|
+
// The session's OAuth UI seams, captured at session_start. The reconnect paths below
|
|
89
|
+
// run outside that handler, and passing undefined there made an INTERACTIVE session
|
|
90
|
+
// report the headless "cannot log in" advice on a re-auth it could actually perform.
|
|
91
|
+
let sessionAuthUi: AuthUi | undefined
|
|
88
92
|
const status = new Map<string, { state: string; tools: number }>()
|
|
89
93
|
// Config per server name, kept for call-time timeout tuning: the idle tier follows
|
|
90
94
|
// the transport kind, and a declared per-server timeout governs the wall budget.
|
|
@@ -178,14 +182,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
178
182
|
/** Claude's mid-session reconnect for a dropped remote server: five attempts with
|
|
179
183
|
* a delay doubling from one second. connectServers redoes the full bring-up
|
|
180
184
|
* (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
|
|
181
|
-
* out if another path already reconnected the name.
|
|
182
|
-
* that now needs a login
|
|
183
|
-
*
|
|
185
|
+
* out if another path already reconnected the name. Uses the session's authUi, so a
|
|
186
|
+
* server that now needs a login can prompt for it in an interactive session; headless
|
|
187
|
+
* still ends failed, and after the fifth failure the last attempt's failed status
|
|
188
|
+
* stands, with a session restart as the manual retry. */
|
|
184
189
|
async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
|
|
185
190
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
186
191
|
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
|
|
187
192
|
if (shuttingDown || clients.has(name)) return
|
|
188
|
-
await connectServers({ [name]: config },
|
|
193
|
+
await connectServers({ [name]: config }, sessionAuthUi, true)
|
|
189
194
|
if (clients.has(name)) return
|
|
190
195
|
}
|
|
191
196
|
}
|
|
@@ -200,7 +205,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
200
205
|
clients.delete(name)
|
|
201
206
|
await withTimeout(old.close(), 3000, 'close').catch(() => {})
|
|
202
207
|
}
|
|
203
|
-
await connectServers({ [name]: config },
|
|
208
|
+
await connectServers({ [name]: config }, sessionAuthUi, true)
|
|
204
209
|
}
|
|
205
210
|
|
|
206
211
|
/** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
|
|
@@ -576,6 +581,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
576
581
|
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
577
582
|
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
|
|
578
583
|
const authUi = authUiFor(ctx)
|
|
584
|
+
sessionAuthUi = authUi
|
|
579
585
|
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
580
586
|
// merge from managed settings plus the trust-gated settings chain, as Claude
|
|
581
587
|
// documents (a repo's file counts only once the project is approved).
|
|
@@ -64,7 +64,7 @@ export async function runInteractiveOAuth(name: string, config: { url: string; o
|
|
|
64
64
|
config.oauth,
|
|
65
65
|
config.url,
|
|
66
66
|
)
|
|
67
|
-
const { server, port } = await startCallbackServer(provider.savedRedirectPort())
|
|
67
|
+
const { server, port } = await startCallbackServer(provider.savedRedirectPort(), provider.configuredRedirectPort() !== undefined)
|
|
68
68
|
provider.bindRedirectPort(port)
|
|
69
69
|
try {
|
|
70
70
|
const transport = makeTransport(provider)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFile } from 'node:child_process'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
8
9
|
import { pathToFileURL } from 'node:url'
|
|
9
10
|
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
10
11
|
// the old spec exist, so this stays as a fallback for the migration period.
|
|
@@ -182,8 +183,8 @@ function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
/** The env a stdio server process starts with: the SDK allowlist, the config's own
|
|
185
|
-
* env block, and Claude's path variables
|
|
186
|
-
*
|
|
186
|
+
* env block, and Claude's three path variables. Claude: "All three are exported as
|
|
187
|
+
* environment variables to hook processes and to MCP and LSP server subprocesses." */
|
|
187
188
|
function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
|
|
188
189
|
// CLAUDECODE marks every subprocess; the long-lived server deliberately gets no
|
|
189
190
|
// CLAUDE_CODE_CHILD_SESSION, which Claude reserves for per-call children.
|
|
@@ -191,6 +192,16 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
|
|
|
191
192
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
192
193
|
if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
|
|
193
194
|
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
195
|
+
// The data dir is "created on first reference"; handing the path to a server is that
|
|
196
|
+
// reference, so the server does not have to mkdir it before using it.
|
|
197
|
+
if (config.pluginDataDir !== undefined) {
|
|
198
|
+
env.CLAUDE_PLUGIN_DATA = config.pluginDataDir
|
|
199
|
+
try {
|
|
200
|
+
fs.mkdirSync(config.pluginDataDir, { recursive: true })
|
|
201
|
+
} catch {
|
|
202
|
+
// The server still starts; one that needs the directory reports its own failure.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
194
205
|
return env
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -234,7 +245,12 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
234
245
|
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
235
246
|
return client
|
|
236
247
|
}
|
|
237
|
-
|
|
248
|
+
// An absent or blank `url` is a server nobody finished configuring, not a malformed
|
|
249
|
+
// one. `new URL('')` throws "Invalid URL", which reads as a typo in a real address and
|
|
250
|
+
// sends people looking for one; name the actual state instead.
|
|
251
|
+
const rawUrl = fill(config.url ?? '').trim()
|
|
252
|
+
if (rawUrl === '') throw new Error(`${name} is not configured: it has no url`)
|
|
253
|
+
const url = new URL(rawUrl)
|
|
238
254
|
if (config.type === 'ws' || config.type === 'websocket') {
|
|
239
255
|
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
240
256
|
// token, or headersHelper output. Warn rather than silently dropping configured
|
package/extensions/skills.ts
CHANGED
|
@@ -39,6 +39,13 @@ import { errorMessage, isDirectory, isRecord } from './internal/values.js'
|
|
|
39
39
|
* directory is included only for approved projects: pi's loader surfaces every skill's
|
|
40
40
|
* name and description to the model, so an untrusted repository would otherwise get
|
|
41
41
|
* text into the prompt without the user ever agreeing to load its config. */
|
|
42
|
+
/** The extra skill directories a manifest declares, as a list. A string is one entry,
|
|
43
|
+
* a list is itself, anything else declares none. */
|
|
44
|
+
function declaredSkillDirs(declared: unknown): string[] {
|
|
45
|
+
if (Array.isArray(declared)) return declared.map(String)
|
|
46
|
+
return typeof declared === 'string' ? [declared] : []
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
43
50
|
// Claude's precedence: enterprise (the skills directory beside the managed
|
|
44
51
|
// settings file) overrides personal, and personal overrides project; discovery
|
|
@@ -48,9 +55,20 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
|
|
|
48
55
|
// skill by its directory, so a plugin skill registers without Claude's
|
|
49
56
|
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|
|
50
57
|
for (const plugin of installedPlugins(home)) {
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
// Claude: "Adds to the default: `skills`. The default `skills/` directory is always
|
|
59
|
+
// scanned, and directories listed in `skills` are loaded alongside it." Treating the
|
|
60
|
+
// declaration as a replacement silently dropped every skill in the conventional
|
|
61
|
+
// location. (The reference's one exception, a marketplace entry whose source resolves
|
|
62
|
+
// to the marketplace root, is a marketplace shape pi-code does not model.)
|
|
63
|
+
const extra = declaredSkillDirs(plugin.manifest.skills)
|
|
64
|
+
const dirs = [...extra, 'skills']
|
|
53
65
|
candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
|
|
66
|
+
// NOT SUPPORTED: Claude's single-skill layout, "a plugin that ships exactly one skill
|
|
67
|
+
// can place SKILL.md directly at the plugin root". skillPaths is handed to pi's own
|
|
68
|
+
// loader, which owns the layout and looks for <dir>/<name>/SKILL.md; adding the plugin
|
|
69
|
+
// root here does not surface root/SKILL.md and does start scanning every sibling
|
|
70
|
+
// directory (hooks/, agents/, commands/) for skills. Supporting it needs a loader that
|
|
71
|
+
// accepts a directory that IS the skill, which is pi's call, not this extension's.
|
|
54
72
|
}
|
|
55
73
|
// Claude loads skills from every .claude/skills between cwd and the repository
|
|
56
74
|
// root; the list goes nearest-first so findClaudeSkill's first match is the
|
|
@@ -352,8 +352,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
352
352
|
// Everything below can touch ctx after an await, and every ctx getter throws
|
|
353
353
|
// once the session is disposed. This promise is started from a timer with no
|
|
354
354
|
// awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
|
|
355
|
-
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS,
|
|
356
|
-
|
|
355
|
+
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, {
|
|
356
|
+
onChild: (kill) => {
|
|
357
|
+
killInflight = kill
|
|
358
|
+
},
|
|
357
359
|
})
|
|
358
360
|
// Claude: "Your script can output multiple lines to create a richer display."
|
|
359
361
|
// pi has one row for every extension status and replaces newlines with spaces
|
|
@@ -56,8 +56,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
56
56
|
|
|
57
57
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
58
58
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
59
|
-
// The stop event fires here too, so SubagentStop hooks see resumed runs end
|
|
60
|
-
|
|
59
|
+
// The stop event fires here too, so SubagentStop hooks see resumed runs end, and it
|
|
60
|
+
// carries the run's final assistant text: docs/subagents.md states SubagentStop
|
|
61
|
+
// receives last_assistant_message unconditionally, and a resumed run is no exception.
|
|
62
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, lastAssistantMessage: run.output })
|
|
61
63
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
62
64
|
}
|
|
63
65
|
|
package/extensions/thinking.ts
CHANGED
|
@@ -23,15 +23,21 @@ export function thinkingRank(level: ThinkingLevel): number {
|
|
|
23
23
|
return Math.max(i, 0)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The reasoning level a prompt requests
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
/** The reasoning level a prompt requests, or undefined when it names none.
|
|
27
|
+
*
|
|
28
|
+
* `ultrathink` is the only keyword, per Claude: "Include `ultrathink` anywhere in your
|
|
29
|
+
* prompt to request deeper reasoning on that turn ... Claude Code passes other phrases
|
|
30
|
+
* such as 'think', 'think hard', and 'think more' through as ordinary prompt text and
|
|
31
|
+
* doesn't recognize them as keywords." Escalating on those surprised anyone who merely
|
|
32
|
+
* used the word in a sentence. Matched on a word boundary so `rethink` and `thinking`
|
|
33
|
+
* never trip it.
|
|
34
|
+
*
|
|
35
|
+
* Divergence: Claude adds an in-context instruction and leaves the API effort level
|
|
36
|
+
* unchanged. pi has no separate in-context channel for this, and its thinking level IS
|
|
37
|
+
* how deeper reasoning is requested, so the keyword raises the level for the turn and
|
|
38
|
+
* restores it after. Same intent, the only mechanism pi has. */
|
|
30
39
|
export function requestedThinkingLevel(text: string): ThinkingLevel | undefined {
|
|
31
|
-
|
|
32
|
-
if (/\bthink harder\b/i.test(text) || /\bthink hard\b/i.test(text)) return 'high'
|
|
33
|
-
if (/\bthink\b/i.test(text)) return 'medium'
|
|
34
|
-
return undefined
|
|
40
|
+
return /\bultrathink\b/i.test(text) ? 'max' : undefined
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export default function thinkingExtension(pi: ExtensionAPI) {
|