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.
@@ -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
- bashRules: string[]
158
- bashUnscoped: boolean
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: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
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 === 'bash') acc.bashUnscoped = true
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 === 'bash') acc.bashRules.push(scope)
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: commands.ts enforces bash scopes at tool_call time,
221
- * and the subagent's frontmatter parsing rejects a scoped grant it cannot express.
222
- * A scope on any other tool is dropped, which widens that grant; bash is the one
223
- * whose widening reaches everything, so it is the one enforced.
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: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
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
- export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
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) {
@@ -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
@@ -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. Runs without authUi: a server
182
- * that now needs a login ends failed, and after the fifth failure the last
183
- * attempt's failed status stands, with a session restart as the manual retry. */
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 }, undefined, true)
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 }, undefined, true)
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 (CLAUDE_PROJECT_DIR, and CLAUDE_PLUGIN_ROOT
186
- * for a plugin's server). */
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
- const url = new URL(fill(config.url))
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
@@ -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
- const declared = plugin.manifest.skills
52
- const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
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, undefined, undefined, (kill) => {
356
- killInflight = kill
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
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
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
 
@@ -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 through Claude's think keywords, or undefined
27
- * when it names none. Checked most-specific first so `think harder` does not fall
28
- * through to the bare-`think` branch, and on word boundaries so `rethink`/`thinking`
29
- * and the whole word `ultrathink` never trip the bare match. */
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
- if (/\bultrathink\b/i.test(text)) return 'max'
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) {