pi-code 1.0.19 → 1.0.21

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
@@ -39,31 +39,21 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
39
39
 
40
40
  ## What it does
41
41
 
42
- | Feature | Reads / provides | Extension |
43
- |---|---|---|
44
- | Global + project rules | `~/.claude/rules`, `.claude/rules` (nearest at or above cwd); unscoped rules inlined in full, `paths:`-scoped rules surfaced as pointers and auto-attached (the rule body is appended to a read/edit/write result when a matching file is touched, once per rule per session) | `claude-rules.ts` |
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
- | `/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
- | 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); invoking `/skill:name` expands the body through the command pipeline, as Claude documents commands and skills "work the same way": `` !`cmd` `` spans, `@file` references, `$ARGUMENTS`/positional substitution and `${CLAUDE_*}` variables, with a malformed-frontmatter skill degrading to pi's plain expansion | `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 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); payloads use Claude's vocabulary for pi's built-ins (Bash/Edit/Write/Read/Grep/Glob names, documented input and Bash/Write response shapes, absolute `file_path`, `updatedInput` translated back); PreToolUse `additionalContext` lands beside the tool result, `updatedToolOutput`/`updatedMCPToolOutput` replace what the model sees, `permissionDecision: "defer"` blocks (pi cannot resume a deferred call), the `if` permission-rule filter runs on tool events only (a hook carrying it never runs elsewhere), Stop/UserPromptSubmit ignore stray matchers, and a Stop hook's `additionalContext` continues the conversation under the block cap; 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
- | 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
- | 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
- | 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` |
52
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE/WebSocket by `type` (WebSocket is url-only: any `headers`/`bearerToken`/`headersHelper` on a ws server is ignored with a warning); `${VAR:-default}` expansion; a `headersHelper` command whose stdout JSON merges into the transport headers (http/sse); a `managed-mcp.json` beside `managed-settings.json` takes exclusive control (only its servers load, every other scope and the approval flow suppressed; an empty file disables MCP); managed `allowedMcpServers`/`deniedMcpServers` read from `managed-settings.json` only (`{serverName}` entries, applied globally across scopes, allow list exclusive with an empty array = lockdown, deny wins); connect and per-call budgets (`MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`) over a 4-hour wall default, plus a 5-minute idle timeout that a progress notification resets (`CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`, `0` disables); a connected server's prompts register as `/mcp__server__prompt` commands and its resources are reachable through the `list_mcp_resources`/`read_mcp_resource` tools; tools refresh on `list_changed`; bearer tokens, or OAuth for remote servers (browser login on 401 after a confirm, CSRF-guarded loopback callback, tokens under `~/.pi/agent/mcp-oauth`, silent refresh later) | `mcp.ts` |
53
- | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
54
- | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents` (scanned recursively into subfolders) merged by default once the project is trusted (project wins on a name clash); frontmatter `tools`/`disallowedTools`/`model` (sonnet/opus/haiku/fable tier aliases or a concrete id)/`effort`/`skills` preload/`permissionMode: plan`/`maxTurns`/`memory` (`user`/`project`/`local` give the child its own persistent store under `.claude/agent-memory[-local]`, injected with Read/Write/Edit enabled, gated on auto memory; the parent conversation's memory is never loaded into a subagent, matching Claude); parallel and chain modes (pi extensions), one nesting level; background runs with cancel and resume (`/tasks` lists them, `/agents` lists the discovered roster) | `subagent/` |
55
- | Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
56
- | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
57
- | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
58
- | Persistent memory | per-repo memories under `~/.pi/agent/memory` keyed on the repository root (subdirectory sessions share one store, as Claude does; pi's own store, separate from Claude's), index injected each session within Claude's 200-line/25KB bound (YAML frontmatter and block HTML comments stripped before it counts or loads); a save that would overflow it reports why; a memory written with frontmatter gets a `modified:` ISO timestamp; honors `autoMemoryEnabled` (settings) and `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (env) to turn it off, and `autoMemoryDirectory` (absolute or `~/`) to relocate the store | `memory.ts` |
59
- | WebSearch / WebFetch | key-free DuckDuckGo search (with `allowed_domains`/`blocked_domains`); SSRF-guarded fetch that prefers markdown via `Accept` then converts HTML, with Claude's 15-minute per-URL cache and an optional `prompt` that runs the page through the model in-process and returns the answer (falls back to markdown when headless or on error) | `web.ts` |
60
- | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header` and 2-4 options, single- or `multiSelect`, plus free-text | `question.ts` |
61
- | Statusline | Claude `statusLine` command contract (stdin JSON incl. `version`, `hook_event_name`, `session_name`, a `cost` block with wall and API durations and lines added/removed, a `context_window` token breakdown, `exceeds_200k_tokens`, and `rate_limits` carrying the `five_hour`/`seven_day` utilization and reset from the provider's rate-limit headers; `padding`, `refreshInterval`, 300ms debounce); built-in turn state + session cost fallback | `status-line.ts` |
62
- | Notifications | terminal notification when a turn ends (OSC 777 / Kitty OSC 99 / Windows toast); honors `preferredNotifChannel` (`terminal_bell`, `notifications_disabled`, `iterm2_with_bell`, else desktop) from user settings; fires only when you "appear to be away" (approximated by turn duration, since pi exposes no terminal-focus signal) | `notify.ts` |
63
- | Think keywords | raises reasoning for one turn when the prompt carries a keyword: `ultrathink` to the max, `think hard`/`think harder` to high, a bare `think` to medium; only ever raises, matched on word boundaries, and the prior level is restored once the turn settles | `thinking.ts` |
64
- | Session title | names a new session from its first message with a single model call (shown in the session selector and the terminal title); never overwrites an existing name, best-effort, at most once per session | `session-title.ts` |
65
- | `/context` | reports how much of the model's context window the session occupies (used, window, free, and percent), and reads pi's live usage so it reflects a compaction | `context-usage.ts` |
66
- | Claude plugins | installed marketplace plugins (`~/.claude/plugins/cache`), active per `enabledPlugins` in user settings only (a checked-out repo cannot flip which code-bearing plugins run, so project settings never toggle them): commands as `/plugin:name`, agents, hooks and MCP servers (tools aliased `mcp__plugin_<plugin>_<server>__<tool>`) and output styles with `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` and `${user_config.KEY}` (from `pluginConfigs[id].options` in user settings) substituted; skill dirs contribute too, though pi's loader names them without the plugin prefix | `internal/plugins.ts` |
42
+ Each topic links to its own doc with the full contract and any divergences from Claude Code.
43
+
44
+ - **[Hooks](docs/hooks.md)** your `.claude/settings.json` hooks on every lifecycle event, with Claude's tool vocabulary, decision fields, and background hooks.
45
+ - **[MCP servers](docs/mcp.md)** every Claude config scope, all four transports, OAuth, managed policy, timeouts, prompts, and resources.
46
+ - **[Custom slash commands](docs/commands.md)** `.claude/commands` with arguments, bash spans, `@file` inlining, frontmatter, and model invocation.
47
+ - **[Skills](docs/skills.md)** `.claude/skills` discovery plus the same dynamic content commands get.
48
+ - **[Subagents / Task](docs/subagents.md)** built-in and custom agents, background runs, per-agent memory, worktree isolation.
49
+ - **[CLAUDE.md, @imports, and rules](docs/claude-md.md)** the context files and path-scoped rules pi does not load natively.
50
+ - **[Settings `env`](docs/settings-env.md)** env blocks from every settings scope, exported with Claude's precedence.
51
+ - **[Output styles](docs/output-styles.md)** replace semantics, bundled built-ins, `/output-style`.
52
+ - **[Persistent memory](docs/memory.md)** a per-repository store with a session-injected index.
53
+ - **[Statusline](docs/statusline.md)** your Claude `statusLine` command with the documented stdin JSON.
54
+ - **[WebSearch / WebFetch](docs/web.md)** key-free search and SSRF-guarded fetch.
55
+ - **[Claude plugins](docs/plugins.md)** installed marketplace plugins: commands, agents, hooks, MCP servers, styles, skills.
56
+ - **[Session extras](docs/session-extras.md)** project trust, plan mode, todos, checkpoints/rewind, AskUserQuestion, notifications, think keywords, session titles, `/context`, `/init`.
67
57
 
68
58
  Slash commands: `/init`, `/context`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
69
59
 
@@ -71,7 +61,7 @@ pi has no general permission system, so most of what Claude routes through a per
71
61
 
72
62
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
73
63
 
74
- `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), `config-dir.ts` (the `CLAUDE_CONFIG_DIR` home-scope resolver), `managed-settings.ts` (the enterprise policy file), `model-complete.ts` (the in-process model calls behind session titling and prompt hooks), `mcp-oauth.ts` (the OAuth loopback), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
64
+ [`extensions/internal/`](extensions/internal) holds the shared modules pi's loader must not treat as extensions (each file's header says what it owns); only `internal/` keeps them out of pi's extension scan.
75
65
 
76
66
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
77
67
 
@@ -104,12 +104,47 @@ function userEnv(home: string): Record<string, string> {
104
104
  return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
105
105
  }
106
106
 
107
+ /** Keys a checked-out repository must not control even once trusted, per Claude's
108
+ * documented drop list: variables that choose where config and files are written
109
+ * (redirecting later home-scope reads and every subprocess), variables that export
110
+ * session content, and variables that change how the agent starts or syncs.
111
+ * PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR. */
112
+ const REPO_HOSTILE_ENV_KEYS = new Set([
113
+ 'CLAUDE_CONFIG_DIR',
114
+ 'CLAUDE_CODE_TMPDIR',
115
+ 'HOME',
116
+ 'TMPDIR',
117
+ 'TMP',
118
+ 'TEMP',
119
+ 'OTEL_LOG_RAW_API_BODIES',
120
+ 'ENABLE_BETA_TRACING_DETAILED',
121
+ 'BETA_TRACING_ENDPOINT',
122
+ 'CLAUDE_CODE_PROCESS_WRAPPER',
123
+ 'CLAUDE_CODE_SYNC_SKILLS',
124
+ 'CLAUDE_CODE_SYNC_PLUGINS',
125
+ 'CLAUDE_CODE_PLUGIN_CACHE_DIR',
126
+ 'CLAUDE_CODE_PLUGIN_SEED_DIR',
127
+ 'PI_CODING_AGENT_DIR',
128
+ ])
129
+
130
+ /** Drop the keys a repository's settings must not set, warning each, as Claude
131
+ * documents ("Claude Code drops each one and logs a warning"). Set them in the
132
+ * shell, user settings, or managed settings instead. */
133
+ export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: string) => void = (key) => console.warn(`pi-code-env: dropping ${key} from project settings env (a checked-out repository must not control it; set it in user or managed settings)`)): Record<string, string> {
134
+ const kept: Record<string, string> = {}
135
+ for (const [key, value] of Object.entries(env)) {
136
+ if (REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_')) warn(key)
137
+ else kept[key] = value
138
+ }
139
+ return kept
140
+ }
141
+
107
142
  /** The project scope's env: .claude/settings.json with settings.local.json overlaid,
108
143
  * each the nearest of its name at or above cwd (matching the hooks settings chain). */
109
144
  function projectEnv(cwd: string): Record<string, string> {
110
145
  const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
111
146
  const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
112
- return { ...base, ...local }
147
+ return sanitizeProjectEnv({ ...base, ...local })
113
148
  }
114
149
 
115
150
  export default function envSettingsExtension(pi: ExtensionAPI) {
@@ -7,7 +7,7 @@ import * as fs from 'node:fs'
7
7
  import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
9
  import { claudeConfigDir } from '../internal/config-dir.js'
10
- import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
10
+ import type { InstalledPlugin } from '../internal/plugins.js'
11
11
  import { findNearestFile } from '../internal/project-root.js'
12
12
 
13
13
  export interface StdioServerConfig {
@@ -108,39 +108,73 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
108
108
  return servers
109
109
  }
110
110
 
111
- /** The mcpServers one plugin declares: an inline map on the manifest, or the file it
112
- * points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
113
- * before parsing. Malformed or missing JSON yields no entries. */
114
- function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
111
+ /** The mcpServers one plugin declares, parsed WITHOUT substitution: an inline map on
112
+ * the manifest, or the file it points to (default .mcp.json at the plugin root).
113
+ * Malformed or missing JSON yields no entries. Substitution happens per field
114
+ * afterwards, so a user_config value with JSON-breaking characters cannot corrupt
115
+ * the parse and headersHelper can be shielded. */
116
+ function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown> {
115
117
  const declared = plugin.manifest.mcpServers
116
118
  // An inline map of name -> config; an array is not a valid mcpServers map (it
117
119
  // would register a server named '0'), so it falls through to the path branch.
118
120
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
119
- try {
120
- return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
121
- } catch {
122
- return {}
123
- }
121
+ return { ...(declared as Record<string, unknown>) }
124
122
  }
125
123
  const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
126
124
  try {
127
- const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
125
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
128
126
  return parsed.mcpServers ?? {}
129
127
  } catch {
130
128
  return {}
131
129
  }
132
130
  }
133
131
 
132
+ /** Every string in the value mapped through `substitute`, arrays and objects walked. */
133
+ function mapStrings(value: unknown, substitute: (text: string) => string): unknown {
134
+ if (typeof value === 'string') return substitute(value)
135
+ if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, substitute))
136
+ if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, substitute)]))
137
+ return value
138
+ }
139
+
140
+ /** One plugin server with Claude's substitutions applied: the plugin path variables
141
+ * and ${CLAUDE_PROJECT_DIR} everywhere, ${user_config.*} everywhere EXCEPT
142
+ * headersHelper (the command runs through a shell, so Claude reports such a server
143
+ * as misconfigured rather than substituting a user-supplied value into it; pi-code
144
+ * skips it with a warning). */
145
+ function substitutedPluginServer(plugin: InstalledPlugin, name: string, config: unknown, projectDir: string | undefined): ServerConfig | undefined {
146
+ if (config === null || typeof config !== 'object') return undefined
147
+ const helper = (config as { headersHelper?: unknown }).headersHelper
148
+ if (typeof helper === 'string' && /\$\{user_config\./.test(helper)) {
149
+ console.warn(`pi-code-mcp: plugin ${plugin.name} server ${name} is misconfigured: headersHelper references \${user_config.*}, which cannot be substituted into a shell command; the server was not loaded`)
150
+ return undefined
151
+ }
152
+ const pathVars = (text: string): string => {
153
+ const withPlugin = substitutePathPluginVars(text, plugin)
154
+ return projectDir === undefined ? withPlugin : withPlugin.replaceAll('${CLAUDE_PROJECT_DIR}', projectDir)
155
+ }
156
+ const full = (text: string): string => pathVars(text).replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '')
157
+ const substituted = mapStrings(config, full) as ServerConfig
158
+ if (typeof helper === 'string') (substituted as { headersHelper?: string }).headersHelper = pathVars(helper)
159
+ return substituted
160
+ }
161
+
162
+ /** The plugin path variables only, without user_config. */
163
+ function substitutePathPluginVars(text: string, plugin: InstalledPlugin): string {
164
+ return text.replaceAll('${CLAUDE_PLUGIN_ROOT}', plugin.root).replaceAll('${CLAUDE_PLUGIN_DATA}', plugin.dataDir)
165
+ }
166
+
134
167
  /** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
135
168
  * inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
136
169
  * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
137
170
  * Claude scopes them. */
138
- export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
171
+ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: string): Record<string, ServerConfig> {
139
172
  const fold = (name: string): string => name.replaceAll('-', '_')
140
173
  const servers: Record<string, ServerConfig> = {}
141
174
  for (const plugin of plugins) {
142
- for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
143
- servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
175
+ for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
176
+ const substituted = substitutedPluginServer(plugin, name, config, projectDir)
177
+ if (substituted) servers[name] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
144
178
  }
145
179
  }
146
180
  return servers
@@ -41,6 +41,7 @@ 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 { repoRoot } from '../internal/project-root.js'
44
45
  import { claudeSettingsChain } from '../internal/settings-chain.js'
45
46
  import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
46
47
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
@@ -424,7 +425,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
424
425
  async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
425
426
  // Plugin servers merge under the user scope (plugins are user-installed);
426
427
  // the user's own entry wins a name clash with a plugin's.
427
- const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
428
+ const pluginServers = loadPluginServers(installedPlugins(os.homedir()), repoRoot(ctx.cwd) ?? ctx.cwd)
428
429
  const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
429
430
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
430
431
  // server only outranks the user's own when it will actually connect (the user already
@@ -213,6 +213,30 @@ function parseIsolationField(raw: unknown): 'worktree' | undefined | null {
213
213
  return null
214
214
  }
215
215
 
216
+ /** Claude's MCP server-level patterns in agent `tools`/`disallowedTools`:
217
+ * `mcp__<server>` or `mcp__<server>__*` covers every tool of that server, and
218
+ * `mcp__*` every MCP tool from any server. pi registers MCP tools under its own
219
+ * `<server>_<tool>` names, so the parent's alias list is the translation table;
220
+ * matching folds case and dashes like hook matchers do. A pattern that matches
221
+ * nothing is kept verbatim (harmless to pi's exact-name filter), so a server that
222
+ * failed to connect is not silently dropped from a deny list. */
223
+ export function expandMcpToolPatterns(entries: string[], aliases: ReadonlyArray<{ pi: string; claude: string }>): string[] {
224
+ const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
225
+ const expanded: string[] = []
226
+ for (const entry of entries) {
227
+ const folded = fold(entry)
228
+ if (!folded.startsWith('mcp__')) {
229
+ expanded.push(entry)
230
+ continue
231
+ }
232
+ const server = folded === 'mcp__*' ? undefined : folded.replace(/__\*$/, '')
233
+ const matches = aliases.filter((alias) => (server === undefined ? true : fold(alias.claude).startsWith(`${server}__`))).map((alias) => alias.pi)
234
+ if (matches.length === 0) expanded.push(entry)
235
+ else expanded.push(...matches)
236
+ }
237
+ return [...new Set(expanded)]
238
+ }
239
+
216
240
  /** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
217
241
  * Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
218
242
  function parseMaxTurns(raw: unknown): number | undefined {
@@ -25,13 +25,14 @@ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
25
25
  import { type Static, Type } from 'typebox'
26
26
  import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
27
27
  import { claudeConfigDir } from '../internal/config-dir.js'
28
+ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
28
29
  import { capForContext } from '../internal/output-guard.js'
29
30
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
30
31
  import { repoRoot } from '../internal/project-root.js'
31
32
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
32
33
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
33
34
  import { skillDirs } from '../skills.js'
34
- import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
35
+ import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
35
36
  import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
36
37
  import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
37
38
  import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
@@ -680,6 +681,15 @@ function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection
680
681
  return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
681
682
  }
682
683
 
684
+ /** The parent's MCP tool aliases, published by the mcp extension on the shared bus;
685
+ * the module-level seam matches setMcpToolCaller's. Children read the same MCP config
686
+ * files, so the parent's roster is the translation table for server-level patterns. */
687
+ let knownMcpAliases: ReadonlyArray<{ pi: string; claude: string }> = []
688
+
689
+ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude: string }>): void {
690
+ knownMcpAliases = aliases
691
+ }
692
+
683
693
  /** CLI args shared by foreground and background children, from the agent's config. */
684
694
  function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
685
695
  const args: string[] = ['--mode', 'json', '-p', '--no-session']
@@ -689,8 +699,11 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
689
699
  const model = agent.model ?? aliasModel
690
700
  if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
691
701
  else if (agent.effort) args.push('--thinking', agent.effort)
692
- if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
693
- if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', agent.disallowedTools.join(','))
702
+ // Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
703
+ // without this a server-level deny removed nothing (fail open) and a server-level
704
+ // grant granted nothing.
705
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
706
+ if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
694
707
  return args
695
708
  }
696
709
 
@@ -1280,6 +1293,13 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1280
1293
  }
1281
1294
 
1282
1295
  export default function subagentExtension(pi: ExtensionAPI) {
1296
+ // Claude's mcp__<server> tool patterns translate against the parent's MCP roster,
1297
+ // published by the mcp extension on the shared bus. Optional-chained so a minimal
1298
+ // test stub without an event bus can still register the extension.
1299
+ pi.events?.on(MCP_TOOLS_CHANNEL, (data) => {
1300
+ if (isMcpToolAliases(data)) setKnownMcpAliases(data)
1301
+ })
1302
+
1283
1303
  // /tasks resolves these against the registry at print time. background.ts owns the
1284
1304
  // run records but does not enumerate them, so the ids started here are remembered;
1285
1305
  // a run the registry has since evicted simply drops out of the listing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
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",