pi-code 1.0.15 → 1.0.16

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 CHANGED
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
45
45
  | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
46
46
  | `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
47
47
  | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
48
- | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
48
+ | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s for PreToolUse, 30s for UserPromptSubmit, both non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
49
49
  | Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
50
50
  | CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
51
51
  | Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Registers Claude Code's custom slash commands with pi directly, rather than
5
5
  * handing `.claude/commands` to pi's prompt-template loader. Owning registration
6
- * is what makes the rest of Claude's command contract reachable: namespaced
7
- * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
- * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
6
+ * is what makes the rest of Claude's command contract reachable: `$ARGUMENTS` and
7
+ * positional substitution, `` !`cmd` `` bash output, `@file` inlining, subdirectory
8
+ * commands (registered as `/frontend:build`; current Claude docs name a command by
9
+ * file name alone, so the qualified form is a pi-code divergence), and the
9
10
  * `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
10
11
  * session model for the command's run via `pi.setModel`, restored on agent_end).
11
12
  * `shell: powershell` runs a command's injected spans through PowerShell when a
@@ -62,8 +62,8 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
62
62
  * the Claude alias, and the payload reports the alias, which is the name a
63
63
  * Claude-written hook script expects in tool_name. Every hook sees the original
64
64
  * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
65
- * hook completes, so with several rewrites the last to finish takes effect, which
66
- * is Claude's documented (non-deterministic) behavior. */
65
+ * hook completes, so with several rewrites the last to finish takes effect (the
66
+ * docs leave multi-rewrite ordering unspecified). */
67
67
  export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
68
68
  const names = claudeName ? [toolName, claudeName] : [toolName]
69
69
  const commands = matchingCommands(config.PreToolUse, names)
@@ -34,8 +34,9 @@
34
34
  *
35
35
  * Every payload carries session_id, transcript_path (pi's session file), cwd,
36
36
  * permission_mode (plan-mode state off the shared bus) and effort; tool events add
37
- * tool_use_id. Every event honors the universal `systemMessage` output (a
38
- * user-facing warning).
37
+ * tool_use_id. The universal `systemMessage` output (a user-facing warning) is
38
+ * honored on the decision-bearing events; the observational paths (Notification,
39
+ * InstructionsLoaded) ignore it, as Claude documents for them.
39
40
  * `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
40
41
  * transcript in the first place.
41
42
  * `async`/`asyncRewake` (command hooks only, as Claude documents) run in the
@@ -111,10 +112,12 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
111
112
  const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
112
113
 
113
114
  /** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
114
- * is a positive integer, else the default. A non-positive or malformed value falls back
115
- * to the default rather than capping at zero (which would suppress the very first block). */
115
+ * is a positive integer, the documented "disable the cap" for 0 (unbounded, not a
116
+ * zero-cap that would suppress the very first block), else the default for a negative
117
+ * or malformed value. */
116
118
  export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
117
119
  const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
120
+ if (override === 0) return Number.POSITIVE_INFINITY
118
121
  return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
119
122
  }
120
123
 
@@ -11,11 +11,13 @@ import { callMcpTool } from '../internal/mcp-call.js'
11
11
  import { completeText } from '../internal/model-complete.js'
12
12
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
13
13
 
14
- // Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
15
- // PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
16
- // to fall back on), so ten minutes of default budget would wedge the turn for
17
- // ten minutes on a hung hook. Hooks that legitimately run long can raise their
18
- // own per-hook `timeout`.
14
+ // Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
15
+ // for prompt, 60s for agent, lowered to 30s on UserPromptSubmit and to a shared
16
+ // 1.5s budget on SessionEnd) and a timed-out hook proceeds; here one flat default
17
+ // applies and a timed-out PreToolUse or UserPromptSubmit hook fails closed (pi
18
+ // has no permission prompt to fall back on), so ten minutes of default budget
19
+ // would wedge the turn on a hung hook. Hooks that legitimately run long can
20
+ // raise their own per-hook `timeout`.
19
21
  const DEFAULT_TIMEOUT_S = 60
20
22
 
21
23
  export interface HookRunResult {
@@ -99,10 +99,12 @@ export function normalizeToolName(name: string): string {
99
99
  }
100
100
 
101
101
  /**
102
- * Entries are comma-separated, except a comma inside an argument scope belongs to the
103
- * scope: `Bash(cat, tail)` is one grant, not three. Splitting on every comma made the
104
- * fragments between them top-level entries, so a command naming only `Bash` came away
105
- * with pi's `edit` tool active.
102
+ * Entries are comma- or space-separated, as Claude documents ("a space- or
103
+ * comma-separated string, or a YAML list"), except a separator inside an argument
104
+ * scope belongs to the scope: `Bash(cat, tail)` and `Bash(git add *)` are one grant
105
+ * each. Splitting on every comma made the fragments between them top-level entries,
106
+ * so a command naming only `Bash` came away with pi's `edit` tool active; splitting
107
+ * on no spaces mangled the docs' own space-separated examples into one garbage rule.
106
108
  *
107
109
  * Scanned rather than matched with a regex: the pattern form is quadratic on an input
108
110
  * of unclosed parens, and a command file comes from the repository.
@@ -114,7 +116,7 @@ function toolEntries(raw: string): string[] {
114
116
  for (const ch of raw) {
115
117
  if (ch === '(') depth++
116
118
  else if (ch === ')') depth = Math.max(0, depth - 1)
117
- if (ch === ',' && depth === 0) {
119
+ if ((ch === ',' || ch === ' ' || ch === '\t') && depth === 0) {
118
120
  entries.push(current)
119
121
  current = ''
120
122
  } else {
@@ -41,10 +41,11 @@ import { setMcpToolCaller } from '../internal/mcp-call.js'
41
41
  import { capForContext } from '../internal/output-guard.js'
42
42
  import { installedPlugins } from '../internal/plugins.js'
43
43
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
44
+ import { claudeSettingsChain } from '../internal/settings-chain.js'
44
45
  import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
45
46
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
46
47
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
47
- import { applyServerPolicy, loadManagedMcpServers, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
48
+ import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
48
49
  import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, withTimeout } from './transport.js'
49
50
 
50
51
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
@@ -57,7 +58,7 @@ export type { McpToolInfo } from './listing.js'
57
58
  export type { McpPromptArgumentInfo, McpPromptInfo, ToolContent } from './mapping.js'
58
59
  export { capTotal, formatPromptCommandName, formatToolName, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
59
60
  export type { ProjectServerPolicy } from './policy.js'
60
- export { applyServerPolicy, loadManagedMcpServers, managedMcpPath, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
61
+ export { applyServerPolicy, loadManagedMcpServers, type McpPolicy, type McpPolicyEntry, managedMcpPath, mcpAllowDeny, projectServerPolicy, splitByPolicy, urlPatternMatches } from './policy.js'
61
62
  export type { AuthUi } from './transport.js'
62
63
  export { parseHelperHeaders, resolveBearerToken } from './transport.js'
63
64
 
@@ -401,8 +402,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
401
402
  * connected client not in the managed set (delete it from the map first so the onclose
402
403
  * handler's guard sees it gone and does not overwrite the status, then close it
403
404
  * best-effort and mark it disabled), then connect only the managed servers. */
404
- async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
405
- const managedServers = applyServerPolicy(managed, allowed, denied)
405
+ async function connectManagedExclusive(managed: Record<string, ServerConfig>, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
406
+ const managedServers = applyServerPolicy(managed, policy)
406
407
  const managedNames = new Set(Object.keys(managedServers))
407
408
  for (const [name, client] of Array.from(clients.entries())) {
408
409
  if (managedNames.has(name)) continue
@@ -420,11 +421,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
420
421
  * factory: pi runs the factory for invocations that never start a session. Names still
421
422
  * connected are filtered out, so a later session start only retries servers that failed
422
423
  * or whose transport dropped, without duplicate-name warnings. */
423
- async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
424
+ async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
424
425
  // Plugin servers merge under the user scope (plugins are user-installed);
425
426
  // the user's own entry wins a name clash with a plugin's.
426
427
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
427
- const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
428
+ const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
428
429
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
429
430
  // server only outranks the user's own when it will actually connect (the user already
430
431
  // consented to it, or an approved project's), so a merely-present untrusted project
@@ -434,7 +435,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
434
435
  // The stored project decision, read without prompting: consent recorded inside
435
436
  // the project only counts once the project itself has been approved.
436
437
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
437
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
438
+ const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
438
439
  const projectWinners = new Set(Object.keys(consented))
439
440
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
440
441
  // The consented project servers carry no ordering dependency on the user scope:
@@ -462,8 +463,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
462
463
  // is why serverToolCount reads from `registered` to recover the true count here.
463
464
  status.clear()
464
465
  const authUi = authUiFor(ctx)
465
- // The managed allow/deny lists filter every scope, including a managed-mcp.json set.
466
- const { allowed, denied } = mcpAllowDeny()
466
+ // The allow/deny lists filter every scope, including a managed-mcp.json set. They
467
+ // merge from managed settings plus the trust-gated settings chain, as Claude
468
+ // documents (a repo's file counts only once the project is approved).
469
+ const policy = mcpAllowDeny(claudeSettingsChain(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx)))
467
470
  // managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
468
471
  // only its servers load, and the user, project, and plugin scopes plus the whole
469
472
  // project-approval flow below are skipped. An empty map disables MCP entirely. An absent
@@ -471,9 +474,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
471
474
  // empty set (see loadManagedMcpServers).
472
475
  const managed = loadManagedMcpServers()
473
476
  if (managed !== null) {
474
- await connectManagedExclusive(managed, allowed, denied, authUi)
477
+ await connectManagedExclusive(managed, policy, authUi)
475
478
  } else {
476
- await connectNormalScopes(ctx, allowed, denied, authUi)
479
+ await connectNormalScopes(ctx, policy, authUi)
477
480
  }
478
481
 
479
482
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
@@ -9,7 +9,7 @@ import * as path from 'node:path'
9
9
  import { claudeConfigDir } from '../internal/config-dir.js'
10
10
  import { managedSettingsFile } from '../internal/managed-settings.js'
11
11
  import { findNearestFile } from '../internal/project-root.js'
12
- import type { ServerConfig } from './config.js'
12
+ import { interpolateEnv, type ServerConfig } from './config.js'
13
13
 
14
14
  export interface ProjectServerPolicy {
15
15
  disabled: Set<string>
@@ -60,31 +60,133 @@ export function splitByPolicy(candidates: Record<string, ServerConfig>, policy:
60
60
  return { consented, gated }
61
61
  }
62
62
 
63
- /** Claude's `allowedMcpServers`/`deniedMcpServers`, read from managed settings only
64
- * (not user or project settings, so a repo can neither widen nor narrow the policy),
65
- * applied globally to every server across scopes. Entries are `{ serverName }` objects
66
- * (bare strings tolerated). `allowed` is null when unset (no restriction); an empty
67
- * set is an explicit lockdown, as Claude documents (empty allow array = deny all). */
68
- export function mcpAllowDeny(managedFile: string = managedSettingsFile()): { allowed: Set<string> | null; denied: Set<string> } {
69
- let settings: Record<string, unknown> = {}
70
- try {
71
- const parsed = JSON.parse(fs.readFileSync(managedFile, 'utf-8'))
72
- if (parsed && typeof parsed === 'object') settings = parsed
73
- } catch {
74
- // No managed policy on this machine: no restriction.
75
- }
76
- const entryName = (entry: unknown): string | undefined => {
77
- if (typeof entry === 'string') return entry
78
- const serverName = (entry as { serverName?: unknown })?.serverName
79
- return typeof serverName === 'string' ? serverName : undefined
63
+ /** One allow/deny list entry, keyed by exactly one of Claude's three match kinds. */
64
+ export interface McpPolicyEntry {
65
+ /** Exact user-assigned label; never expanded. */
66
+ serverName?: string
67
+ /** Remote server URL, exact or with `*` wildcards anywhere. */
68
+ serverUrl?: string
69
+ /** Exact command and arguments that start a stdio server. */
70
+ serverCommand?: string[]
71
+ }
72
+
73
+ export interface McpPolicy {
74
+ /** null when no honored scope sets allowedMcpServers: every server passing the denylist loads. */
75
+ allowed: McpPolicyEntry[] | null
76
+ denied: McpPolicyEntry[]
77
+ }
78
+
79
+ /** A raw list entry into its typed form. A bare string is a name (Claude tolerates it);
80
+ * an object is keyed serverUrl > serverCommand > serverName; anything else is dropped. */
81
+ function parsePolicyEntry(entry: unknown): McpPolicyEntry | undefined {
82
+ if (typeof entry === 'string') return entry.length > 0 ? { serverName: entry } : undefined
83
+ if (entry === null || typeof entry !== 'object') return undefined
84
+ const { serverName, serverUrl, serverCommand } = entry as Record<string, unknown>
85
+ if (typeof serverUrl === 'string' && serverUrl.length > 0) return { serverUrl }
86
+ if (Array.isArray(serverCommand) && serverCommand.length > 0 && serverCommand.every((part): part is string => typeof part === 'string')) return { serverCommand: serverCommand as string[] }
87
+ if (typeof serverName === 'string' && serverName.length > 0) return { serverName }
88
+ return undefined
89
+ }
90
+
91
+ /** Claude's `allowedMcpServers`/`deniedMcpServers`. Both lists merge from every honored
92
+ * settings scope (the caller passes the trust-gated chain, so a repo's file counts only
93
+ * once the project is approved); managed `allowManagedMcpServersOnly: true` keeps only
94
+ * the managed allowlist, while the denylist always merges from every scope, as Claude
95
+ * documents. An allowlist `serverName` entry is limited to letters, numbers, hyphens and
96
+ * underscores (a denylist name accepts any non-empty string). `allowed` is null when no
97
+ * honored scope sets the key; an empty list is an explicit lockdown (deny all). */
98
+ export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = managedSettingsFile()): McpPolicy {
99
+ const read = (file: string): Record<string, unknown> => {
100
+ try {
101
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
102
+ return parsed && typeof parsed === 'object' ? parsed : {}
103
+ } catch {
104
+ // Missing or invalid file contributes no policy.
105
+ return {}
106
+ }
80
107
  }
81
- const names = (value: unknown): string[] => (Array.isArray(value) ? value.map(entryName).filter((name): name is string => typeof name === 'string' && name.length > 0) : [])
108
+ const entries = (value: unknown): McpPolicyEntry[] => (Array.isArray(value) ? value.map(parsePolicyEntry).filter((entry): entry is McpPolicyEntry => entry !== undefined) : [])
109
+ const managed = read(managedFile)
110
+ const scopes = scopeFiles.map(read)
111
+ const allowSources = managed.allowManagedMcpServersOnly === true ? [managed] : [managed, ...scopes]
112
+ const allowSet = allowSources.some((settings) => Array.isArray(settings.allowedMcpServers))
82
113
  return {
83
- allowed: Array.isArray(settings.allowedMcpServers) ? new Set(names(settings.allowedMcpServers)) : null,
84
- denied: new Set(names(settings.deniedMcpServers)),
114
+ allowed: allowSet ? allowSources.flatMap((settings) => entries(settings.allowedMcpServers)).filter((entry) => entry.serverName === undefined || /^[A-Za-z0-9_-]+$/.test(entry.serverName)) : null,
115
+ denied: [managed, ...scopes].flatMap((settings) => entries(settings.deniedMcpServers)),
85
116
  }
86
117
  }
87
118
 
119
+ /** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
120
+ function wildcardRegExp(pattern: string): RegExp {
121
+ const source = pattern
122
+ .split('*')
123
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
124
+ .join('.*')
125
+ return new RegExp(`^${source}$`)
126
+ }
127
+
128
+ /** Claude's URL pattern match: `*` anywhere including the scheme; the scheme/host/port
129
+ * part compares case-insensitively and ignores a trailing FQDN dot, the path stays
130
+ * case-sensitive, and a pattern with no path matches any path. */
131
+ export function urlPatternMatches(pattern: string, url: string): boolean {
132
+ const split = (value: string): { pre: string; path: string | undefined } => {
133
+ const schemeEnd = value.indexOf('://')
134
+ const slash = value.indexOf('/', schemeEnd === -1 ? 0 : schemeEnd + 3)
135
+ if (slash === -1) return { pre: value, path: undefined }
136
+ return { pre: value.slice(0, slash), path: value.slice(slash) }
137
+ }
138
+ const pre = (value: string): string => value.toLowerCase().replace(/\.(?=:\d+$|$)/, '')
139
+ const patternParts = split(pattern)
140
+ const urlParts = split(url)
141
+ if (!wildcardRegExp(pre(patternParts.pre)).test(pre(urlParts.pre))) return false
142
+ if (patternParts.path === undefined) return true
143
+ return wildcardRegExp(patternParts.path).test(urlParts.path ?? '/')
144
+ }
145
+
146
+ const configUrl = (config: ServerConfig): string | undefined => (config as { url?: string }).url
147
+ const configArgv = (config: ServerConfig): string[] | undefined => {
148
+ const command = (config as { command?: string }).command
149
+ if (typeof command !== 'string') return undefined
150
+ const args = (config as { args?: unknown }).args
151
+ return [command, ...(Array.isArray(args) ? args.filter((part): part is string => typeof part === 'string') : [])]
152
+ }
153
+ const argvEqual = (a: string[], b: string[]): boolean => a.length === b.length && a.every((part, i) => part === b[i])
154
+ /** Policy-side expansion. Divergence from Claude, documented: Claude expands policy
155
+ * entries from a pinned startup environment; pi-code expands from the live process env.
156
+ * The deny path compensates by also matching the unexpanded forms (see entryMatches),
157
+ * which can only widen a deny, never weaken it. */
158
+ const expand = (value: string): string => interpolateEnv(value)
159
+
160
+ /** Whether one policy entry matches a server. `includeRawForms` is the deny-side rule:
161
+ * raw and expanded forms both count, so expansion drift can only widen a deny. The
162
+ * allow side matches expanded forms only, mirroring Claude ignoring an allow entry
163
+ * whose expansion would change what it means. */
164
+ function entryMatches(entry: McpPolicyEntry, name: string, config: ServerConfig, includeRawForms: boolean): boolean {
165
+ if (entry.serverUrl !== undefined) {
166
+ const url = configUrl(config)
167
+ if (url === undefined) return false
168
+ return urlPatternMatches(expand(entry.serverUrl), expand(url)) || (includeRawForms && urlPatternMatches(entry.serverUrl, url))
169
+ }
170
+ if (entry.serverCommand !== undefined) {
171
+ const argv = configArgv(config)
172
+ if (argv === undefined) return false
173
+ return argvEqual(entry.serverCommand.map(expand), argv.map(expand)) || (includeRawForms && argvEqual(entry.serverCommand, argv))
174
+ }
175
+ return entry.serverName === name
176
+ }
177
+
178
+ /** Claude's allowlist rule per server type: a remote server must match a `serverUrl`
179
+ * entry and a stdio server a `serverCommand` entry; a `serverName` match counts only
180
+ * when the allowlist contains no typed entries for that transport. */
181
+ function serverAllowed(name: string, config: ServerConfig, allowed: McpPolicyEntry[] | null): boolean {
182
+ if (allowed === null) return true
183
+ const remote = configUrl(config) !== undefined
184
+ const typed = allowed.filter((entry) => (remote ? entry.serverUrl !== undefined : entry.serverCommand !== undefined))
185
+ if (typed.some((entry) => entryMatches(entry, name, config, false))) return true
186
+ if (typed.length > 0) return false
187
+ return allowed.some((entry) => entry.serverName === name)
188
+ }
189
+
88
190
  /** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
89
191
  * through the same test seam so a test can write both into one temp dir. */
90
192
  export function managedMcpPath(managedFile: string = managedSettingsFile()): string {
@@ -126,14 +228,14 @@ export function loadManagedMcpServers(managedFile: string = managedSettingsFile(
126
228
  return servers as Record<string, ServerConfig>
127
229
  }
128
230
 
129
- /** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
130
- * a set (even empty) is exclusive, so only its members survive; a deny list removes
131
- * servers on top, deny winning over allow. */
132
- export function applyServerPolicy(servers: Record<string, ServerConfig>, allowed: ReadonlySet<string> | null, denied: ReadonlySet<string>): Record<string, ServerConfig> {
231
+ /** Claude's allow/deny lists: a denylist match by URL, command or name blocks, and
232
+ * nothing overrides it; then a null allowlist keeps all, while a present one (even
233
+ * empty) is exclusive per the typed rule in serverAllowed. */
234
+ export function applyServerPolicy(servers: Record<string, ServerConfig>, policy: McpPolicy): Record<string, ServerConfig> {
133
235
  const out: Record<string, ServerConfig> = {}
134
236
  for (const [name, config] of Object.entries(servers)) {
135
- if (denied.has(name)) continue
136
- if (allowed !== null && !allowed.has(name)) continue
237
+ if (policy.denied.some((entry) => entryMatches(entry, name, config, true))) continue
238
+ if (!serverAllowed(name, config, policy.allowed)) continue
137
239
  out[name] = config
138
240
  }
139
241
  return out
@@ -144,7 +144,9 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
144
144
  // The SDK's WebSocket transport takes only a url: it carries no headers, bearer
145
145
  // token, or headersHelper output. Warn rather than silently dropping configured
146
146
  // auth, and skip the helper entirely (running it would block the connect for up to
147
- // 10s while contributing nothing). A ws server must be reachable without auth.
147
+ // 10s while contributing nothing). Divergence: Claude documents header auth as the
148
+ // ws mechanism ("Authentication is header-only"); under pi an authenticated ws
149
+ // server cannot be used until the SDK transport grows header support.
148
150
  if (config.headers || config.bearerToken || config.bearerTokenEnv || config.headersHelper) {
149
151
  console.warn(`pi-code-mcp: server ${name} is a WebSocket server; the SDK ws transport is url-only, so its headers/bearerToken/headersHelper are ignored`)
150
152
  }
@@ -114,15 +114,16 @@ Claude Code fields map onto pi where a sensible seam exists: `tools` and
114
114
  `--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model, or
115
115
  `--thinking` when no model is pinned;
116
116
  `permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
117
- aliases (`sonnet`, `opus`, `haiku`) resolve against the models this machine is
118
- authenticated for, falling back to the session's default model when that tier is
117
+ aliases (`sonnet`, `opus`, `haiku`, `fable`) resolve against the models this machine
118
+ is authenticated for, falling back to the session's default model when that tier is
119
119
  unavailable; `inherit` is the session model by definition. `skills` names skills to preload: their bodies are inlined into the child's
120
120
  prompt, since a child pi process does not inherit the parent's skill discovery, and
121
121
  a name that resolves to nothing is reported in the prompt rather than dropped.
122
122
  Fields with no pi seam are ignored, each verified against pi's CLI rather than
123
- assumed: `maxTurns` (no turn-limit flag), `mcpServers` (a child reads MCP config
124
- from files, and writing config into the workspace to fake it would be worse than
125
- the gap), and `memory` (pi-code's memory is per project, not per agent).
123
+ assumed: `mcpServers` (a child reads MCP config from files, and writing config
124
+ into the workspace to fake it would be worse than the gap). `maxTurns` and
125
+ `memory` are honored (turn cap enforced at the turn boundary; per-agent memory
126
+ directories injected into the child's prompt).
126
127
 
127
128
  **Locations:**
128
129
  - `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",