pi-code 1.0.20 → 1.0.22

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.
@@ -13,19 +13,21 @@
13
13
  * approval-gated on purpose: a checked-out repository's env can redirect providers
14
14
  * (ANTHROPIC_BASE_URL and friends), so an untrusted repo must not reach process.env.
15
15
  *
16
- * Precedence is per key, managed > user > project, matching Claude's merge: a scope
17
- * only supplies keys it names and never wipes another scope's keys. Values must be
18
- * strings; a number or boolean is coerced via String, anything else is skipped.
16
+ * Precedence is per key, managed > project (settings.local.json overlaying
17
+ * settings.json inside the project scope) > user, matching Claude's settings
18
+ * precedence: a scope only supplies keys it names and never wipes another scope's
19
+ * keys. Values must be strings; a number or boolean is coerced via String,
20
+ * anything else is skipped.
19
21
  *
20
- * A variable already present in the real environment that this module did not set is
21
- * left untouched for the user and project scopes: a shell `export` outranks them. The
22
- * managed scope is the exception: an org policy env must win over an ambient export, so
23
- * a managed key overwrites a preexisting shell value. The keys this module sets are
24
- * recorded so a later refresh can update them without clobbering unrelated env, and a
25
- * key an earlier apply set that the current merge no longer defines is unset (deleted
26
- * from process.env), so an approved project's env cannot leak into a later session or
27
- * project that does not define it. A managed overwrite of a shell var is owned like any
28
- * other set key; its original shell value cannot be restored, so on unset it is deleted.
22
+ * A settings value replaces a value inherited from the shell, as Claude documents
23
+ * ("Claude Code writes each env entry into the process environment, replacing the
24
+ * value inherited from the shell"), and an empty string is the documented way to
25
+ * override an export that cannot be unset. The original value of each key is
26
+ * recorded so a later apply that no longer defines the key restores the shell's
27
+ * value (or deletes a key the shell never had), so an approved project's env
28
+ * cannot leak into a later session or project that does not define it. The keys a
29
+ * repository must not control are dropped from the project scope before any of
30
+ * this (see sanitizeProjectEnv).
29
31
  *
30
32
  * Docs: https://code.claude.com/docs/en/settings.md
31
33
  */
@@ -57,35 +59,31 @@ export function envFromSettings(settings: unknown): Record<string, string> {
57
59
  return out
58
60
  }
59
61
 
60
- /** Merge the three env scopes with Claude's per-key precedence managed > user >
61
- * project: lower scopes are laid down first and higher ones overlay, so each key
62
- * takes its highest-precedence value and no scope wipes another's keys. */
62
+ /** Merge the three env scopes with Claude's per-key settings precedence managed >
63
+ * project > user: lower scopes are laid down first and higher ones overlay, so each
64
+ * key takes its highest-precedence value and no scope wipes another's keys. */
63
65
  export function mergeEnvScopes(managed: Record<string, string>, user: Record<string, string>, project: Record<string, string>): Record<string, string> {
64
- return { ...project, ...user, ...managed }
66
+ return { ...user, ...project, ...managed }
65
67
  }
66
68
 
67
- /** Assign the merged env into `env`, recording each key set into `owned`. A key already
68
- * present that this module did not set (a shell export) is left untouched for user and
69
- * project keys; a `managedKeys` entry overwrites it (an org policy outranks the shell).
70
- * A key this module set before is updated. A previously-owned key that the current
71
- * `merged` no longer defines is unset (deleted from `env` and `owned`), so an approved
72
- * project's env cannot leak into a later apply that does not define it. */
73
- export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Set<string>, managedKeys: ReadonlySet<string> = new Set()): void {
74
- // Unset any key an earlier apply set that the current merge dropped. Iterate a copy
75
- // since `owned` is mutated. A shell export this module never owned is left in place.
76
- for (const key of Array.from(owned)) {
77
- if (!(key in merged)) {
78
- delete env[key]
79
- owned.delete(key)
80
- }
69
+ /** Assign the merged env into `env`. Every settings value applies, replacing a
70
+ * shell-inherited value, as Claude documents; an empty string is the documented
71
+ * override for an export that cannot be unset. `owned` records each key's original
72
+ * value at first ownership, so a later apply that drops the key restores the
73
+ * shell's value (or deletes a key the shell never had) rather than leaking a stale
74
+ * setting into the rest of the process. */
75
+ export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Map<string, string | undefined>): void {
76
+ // Restore any key an earlier apply set that the current merge dropped. Iterate a
77
+ // copy since `owned` is mutated.
78
+ for (const [key, original] of Array.from(owned.entries())) {
79
+ if (key in merged) continue
80
+ if (original === undefined) delete env[key]
81
+ else env[key] = original
82
+ owned.delete(key)
81
83
  }
82
84
  for (const [key, value] of Object.entries(merged)) {
83
- // A shell export outranks user/project (skip), but a managed key outranks even the
84
- // shell. Once owned, updates always apply. A managed overwrite is owned like any
85
- // set key: its shell value is gone and cannot be restored, so on unset it is deleted.
86
- if (key in env && !owned.has(key) && !managedKeys.has(key)) continue
85
+ if (!owned.has(key)) owned.set(key, env[key])
87
86
  env[key] = value
88
- owned.add(key)
89
87
  }
90
88
  }
91
89
 
@@ -104,20 +102,54 @@ function userEnv(home: string): Record<string, string> {
104
102
  return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
105
103
  }
106
104
 
105
+ /** Keys a checked-out repository must not control even once trusted, per Claude's
106
+ * documented drop list: variables that choose where config and files are written
107
+ * (redirecting later home-scope reads and every subprocess), variables that export
108
+ * session content, and variables that change how the agent starts or syncs.
109
+ * PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR. */
110
+ const REPO_HOSTILE_ENV_KEYS = new Set([
111
+ 'CLAUDE_CONFIG_DIR',
112
+ 'CLAUDE_CODE_TMPDIR',
113
+ 'HOME',
114
+ 'TMPDIR',
115
+ 'TMP',
116
+ 'TEMP',
117
+ 'OTEL_LOG_RAW_API_BODIES',
118
+ 'ENABLE_BETA_TRACING_DETAILED',
119
+ 'BETA_TRACING_ENDPOINT',
120
+ 'CLAUDE_CODE_PROCESS_WRAPPER',
121
+ 'CLAUDE_CODE_SYNC_SKILLS',
122
+ 'CLAUDE_CODE_SYNC_PLUGINS',
123
+ 'CLAUDE_CODE_PLUGIN_CACHE_DIR',
124
+ 'CLAUDE_CODE_PLUGIN_SEED_DIR',
125
+ 'PI_CODING_AGENT_DIR',
126
+ ])
127
+
128
+ /** Drop the keys a repository's settings must not set, warning each, as Claude
129
+ * documents ("Claude Code drops each one and logs a warning"). Set them in the
130
+ * shell, user settings, or managed settings instead. */
131
+ 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> {
132
+ const kept: Record<string, string> = {}
133
+ for (const [key, value] of Object.entries(env)) {
134
+ if (REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_')) warn(key)
135
+ else kept[key] = value
136
+ }
137
+ return kept
138
+ }
139
+
107
140
  /** The project scope's env: .claude/settings.json with settings.local.json overlaid,
108
141
  * each the nearest of its name at or above cwd (matching the hooks settings chain). */
109
142
  function projectEnv(cwd: string): Record<string, string> {
110
143
  const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
111
144
  const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
112
- return { ...base, ...local }
145
+ return sanitizeProjectEnv({ ...base, ...local })
113
146
  }
114
147
 
115
148
  export default function envSettingsExtension(pi: ExtensionAPI) {
116
- const owned = new Set<string>()
149
+ const owned = new Map<string, string | undefined>()
117
150
 
118
151
  const apply = (home: string, project: Record<string, string>): void => {
119
- const managed = envFromSettings(readManagedSettings())
120
- applyEnvSettings(mergeEnvScopes(managed, userEnv(home), project), process.env, owned, new Set(Object.keys(managed)))
152
+ applyEnvSettings(mergeEnvScopes(envFromSettings(readManagedSettings()), userEnv(home), project), process.env, owned)
121
153
  }
122
154
 
123
155
  // Factory time: managed + user only. Approval needs the session ctx, so the project
@@ -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.20",
3
+ "version": "1.0.22",
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",