pi-code 1.2.1 → 1.3.0

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
@@ -42,7 +42,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
42
42
  Each topic links to its own doc with the full contract and any divergences from Claude Code.
43
43
 
44
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.
45
+ - **[MCP servers](docs/mcp.md)** — every Claude config scope, all four transports, OAuth, managed policy, timeouts, prompts, and resources. On pi 0.99 and later the servers are handed to pi's own MCP support.
46
46
  - **[Custom slash commands](docs/commands.md)** — `.claude/commands` with arguments, bash spans, `@file` inlining, frontmatter, and model invocation.
47
47
  - **[Skills](docs/skills.md)** — `.claude/skills` discovery plus the same dynamic content commands get.
48
48
  - **[Subagents](docs/subagents.md)** — built-in and custom agents, background runs, per-agent memory, worktree isolation.
@@ -90,19 +90,34 @@ function claudeJsonPath(home: string): string {
90
90
  return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
91
91
  }
92
92
 
93
+ /** Claude's own user-scoped MCP config, without pi's mcp.json: what pi-code reads when
94
+ * pi's native MCP reads pi's files itself. */
95
+ export function claudeUserConfigPaths(home: string): string[] {
96
+ return [claudeJsonPath(home)]
97
+ }
98
+
93
99
  /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
94
100
  * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
95
101
  export function userConfigPaths(home: string): string[] {
96
102
  // mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
97
103
  // other agent-directory readers (trust store, OAuth tokens) already follow it.
98
- return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
104
+ return [...claudeUserConfigPaths(home), path.join(getAgentDir(), 'mcp.json')]
99
105
  }
100
106
 
101
107
  /** Project-scoped MCP config, each file the nearest of its name at or above cwd
102
108
  * (bounded at the repository root, matching the approval walk). Loaded only for
103
109
  * trusted projects: a server's `command` runs on connect. */
104
110
  export function projectConfigPaths(cwd: string): string[] {
105
- return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
111
+ return [...claudeProjectConfigPaths(cwd), nearestConfig(cwd, path.join('.pi', 'mcp.json'))]
112
+ }
113
+
114
+ /** Claude's own project MCP config, without pi's .pi/mcp.json. */
115
+ export function claudeProjectConfigPaths(cwd: string): string[] {
116
+ return [nearestConfig(cwd, '.mcp.json')]
117
+ }
118
+
119
+ function nearestConfig(cwd: string, rel: string): string {
120
+ return findNearestFile(cwd, rel) ?? path.join(cwd, rel)
106
121
  }
107
122
 
108
123
  /** The object entries of a raw `mcpServers` map. A string or null entry (a JSON "comment"
@@ -140,8 +155,8 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
140
155
  * Both are the user's own config, so neither needs project trust; local wins on a name
141
156
  * clash (Claude's precedence is local over user).
142
157
  */
143
- export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
144
- const servers = loadConfigFrom(userConfigPaths(home))
158
+ export function loadUserScope(home: string, cwd: string, files: string[] = userConfigPaths(home)): Record<string, ServerConfig> {
159
+ const servers = loadConfigFrom(files)
145
160
  Object.assign(servers, serverEntries(projectRecord(home, cwd).mcpServers, claudeJsonPath(home)))
146
161
  return servers
147
162
  }
@@ -32,6 +32,7 @@
32
32
  * so the byte cap stands in for it.
33
33
  */
34
34
 
35
+ import * as fs from 'node:fs'
35
36
  import * as os from 'node:os'
36
37
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
37
38
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
@@ -45,9 +46,10 @@ import { isProjectApproved, isProjectApprovedSilently } from '../internal/projec
45
46
  import { checkoutRoot } from '../internal/project-root.js'
46
47
  import { claudeSettingsChain } from '../internal/settings-chain.js'
47
48
  import { errorMessage } from '../internal/values.js'
48
- import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
49
+ import { claudeProjectConfigPaths, claudeUserConfigPaths, disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, userConfigPaths, warnOnTypelessUrl } from './config.js'
49
50
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
50
51
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
52
+ import { nativeMode, nativeToolAliases, piMcpRunning, toNativeServer } from './native.js'
51
53
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
52
54
  import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isConnectionLost, isUnauthorized, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
53
55
 
@@ -84,6 +86,13 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
84
86
 
85
87
  export default async function mcpExtension(pi: ExtensionAPI) {
86
88
  const clients = new Map<string, Client>()
89
+ // pi 0.99 and later connect MCP servers themselves (docs/specs/mcp-native.md). Decided
90
+ // at load, since registering /mcp here is what drops pi's own; project settings count
91
+ // only once approved, so the policy read here is the managed and user one.
92
+ const native = nativeMode(pi, loadManagedMcpServers(), mcpAllowDeny(claudeSettingsChain(process.cwd(), os.homedir(), false)))
93
+ // Whether pi's /mcp is running this session, and the names handed to pi.registerMcpServer.
94
+ let nativeActive = false
95
+ const nativeRegistered = new Set<string>()
87
96
  // Names with a connect in flight. A name enters `clients` only once its connect
88
97
  // resolves, and shutdown clears that map without awaiting anything in flight, so
89
98
  // without this set a session switch during a slow connect (or a backoff sleep) let
@@ -135,6 +144,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
135
144
  const registered = new Map<string, string>()
136
145
  // Original server/tool names per registered pi name, for Claude-style hook matchers.
137
146
  const aliases: McpToolAlias[] = []
147
+ // The roster other extensions read: pi-code's own aliases plus the tools pi connected.
148
+ const roster = (): McpToolAlias[] => [...aliases, ...(nativeActive ? nativeToolAliases(pi.getAllTools()) : [])]
138
149
 
139
150
  /** How many tools a server actually has registered. Counted from `registered` (the
140
151
  * durable owner map) rather than registerTools' return, so a reconnect on a second
@@ -367,6 +378,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
367
378
  * fetched live on every call, so a resources list_changed needs no cache
368
379
  * invalidation; its handler only re-checks this gate (see subscribeToResourceChanges). */
369
380
  function ensureResourceTools(): void {
381
+ // pi registers list_mcp_resources and read_mcp_resource for the servers it connects.
382
+ if (nativeActive) return
370
383
  if (resourceToolsRegistered || resourceServers().length === 0) return
371
384
  resourceToolsRegistered = true
372
385
  pi.registerTool({
@@ -433,7 +446,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
433
446
  if (added === 0) return
434
447
  const current = status.get(name)
435
448
  status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
436
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
449
+ pi.events.emit(MCP_TOOLS_CHANNEL, roster())
437
450
  } catch (error) {
438
451
  console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${errorMessage(error)}`)
439
452
  }
@@ -443,9 +456,37 @@ export default async function mcpExtension(pi: ExtensionAPI) {
443
456
  }
444
457
  }
445
458
 
459
+ /** Hands every server pi can connect to pi.registerMcpServer and returns the rest, which
460
+ * pi-code connects itself. */
461
+ function registerWithPi(servers: Record<string, ServerConfig>): Record<string, ServerConfig> {
462
+ const own: Record<string, ServerConfig> = {}
463
+ for (const [name, config] of Object.entries(servers)) {
464
+ const translated = toNativeServer(name, config, sessionDirs)
465
+ if (!('native' in translated)) {
466
+ own[name] = config
467
+ continue
468
+ }
469
+ if (config.pluginDataDir !== undefined) {
470
+ try {
471
+ fs.mkdirSync(config.pluginDataDir, { recursive: true })
472
+ } catch {
473
+ // The server still starts; one that needs the directory reports its own failure.
474
+ }
475
+ }
476
+ try {
477
+ pi.registerMcpServer(translated.native.name, translated.native.config)
478
+ nativeRegistered.add(translated.native.name)
479
+ } catch (error) {
480
+ console.warn(`pi-code-mcp: pi did not accept server ${name} (${errorMessage(error)}); connecting it directly`)
481
+ own[name] = config
482
+ }
483
+ }
484
+ return own
485
+ }
486
+
446
487
  async function connectServers(servers: Record<string, ServerConfig>, authUi?: AuthUi, noRetry = false): Promise<void> {
447
488
  const pending: [string, ServerConfig][] = []
448
- for (const [name, config] of Object.entries(servers)) {
489
+ for (const [name, config] of Object.entries(nativeActive ? registerWithPi(servers) : servers)) {
449
490
  // A later scope must not take the name of a server that already connected: it
450
491
  // would evict that client from the map, leaking it at shutdown, and misreport
451
492
  // the earlier server's status.
@@ -565,7 +606,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
565
606
  // in ~/.claude.json's per-project disabledMcpServers list never connects.
566
607
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()), checkoutRoot(ctx.cwd))
567
608
  const disabled = disabledServerNames(os.homedir(), ctx.cwd)
568
- const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }).filter(([name]) => !disabled.has(name)))
609
+ const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd, nativeActive ? claudeUserConfigPaths(os.homedir()) : userConfigPaths(os.homedir())) }).filter(([name]) => !disabled.has(name)))
569
610
  const scoped = applyServerPolicy(merged, policy)
570
611
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
571
612
  // server only outranks the user's own when it will actually connect (the user already
@@ -579,7 +620,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
579
620
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), projectApproved)
580
621
  // Tag the scope on each project server: a repository-supplied headersHelper runs
581
622
  // with credential variables stripped, unlike a user-scope one.
582
- const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
623
+ const projectServers = Object.fromEntries(Object.entries(loadConfigFrom(nativeActive ? claudeProjectConfigPaths(ctx.cwd) : projectConfigPaths(ctx.cwd))).map(([name, config]) => [name, { ...config, projectScope: true }]))
583
624
  const { consented: consentedRaw, gated } = splitByPolicy(applyServerPolicy(projectServers, policy), projectPolicy)
584
625
  // Claude's scope precedence is local over project: a name the local scope defines
585
626
  // stays with the local (user-side) definition, so the project's entry is dropped
@@ -619,7 +660,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
619
660
  * then) and again when a still-connecting tail finishes, so a server that connects
620
661
  * late still gets its aliases published and counted. */
621
662
  function publishConnectionSummary(ctx: ExtensionContext): void {
622
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
663
+ pi.events.emit(MCP_TOOLS_CHANNEL, roster())
623
664
  const connected = [...status.values()].filter((s) => s.state === 'connected')
624
665
  const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
625
666
  if (connected.length > 0 || failed.length > 0) {
@@ -647,6 +688,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
647
688
  // Claude answers roots/list with the session's launch directory and exports the
648
689
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
649
690
  sessionDirs = { projectDir: checkoutRoot(ctx.cwd), launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
691
+ nativeActive = native && piMcpRunning(pi.getCommands())
650
692
  const authUi = authUiFor(ctx)
651
693
  sessionAuthUi = authUi
652
694
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
@@ -705,6 +747,20 @@ export default async function mcpExtension(pi: ExtensionAPI) {
705
747
  // closure on a closed client. session_start resets status too, so a switch rebuilds it.
706
748
  clients.clear()
707
749
  status.clear()
750
+ for (const name of nativeRegistered) {
751
+ try {
752
+ pi.unregisterMcpServer(name)
753
+ } catch {
754
+ // pi is tearing the session down; a name it no longer knows needs no removal.
755
+ }
756
+ }
757
+ nativeRegistered.clear()
758
+ })
759
+
760
+ // pi's MCP tools register as their servers connect, after session_start; each turn
761
+ // republishes the roster so hook matchers and subagent patterns see them.
762
+ pi.on('turn_start', () => {
763
+ if (nativeActive) pi.events.emit(MCP_TOOLS_CHANNEL, roster())
708
764
  })
709
765
 
710
766
  // Claude references MCP resources with @server:uri mentions, fetched into the
@@ -735,15 +791,17 @@ export default async function mcpExtension(pi: ExtensionAPI) {
735
791
  return { action: 'transform', text: `${event.text}\n\n${sections.join('\n\n')}` }
736
792
  })
737
793
 
738
- pi.registerCommand('mcp', {
739
- description: 'Show MCP server status and tools',
740
- handler: async (_args, ctx) => {
741
- if (status.size === 0) {
742
- ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
743
- return
744
- }
745
- const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
746
- ctx.ui.notify(lines.join('\n'), 'info')
747
- },
748
- })
794
+ // In native mode /mcp is pi's.
795
+ if (!native)
796
+ pi.registerCommand('mcp', {
797
+ description: 'Show MCP server status and tools',
798
+ handler: async (_args, ctx) => {
799
+ if (status.size === 0) {
800
+ ctx.ui.notify('No MCP servers configured. Add them to .mcp.json, .pi/mcp.json, ~/.claude.json, or ~/.pi/agent/mcp.json', 'info')
801
+ return
802
+ }
803
+ const lines = [...status.entries()].map(([name, s]) => `${name}: ${s.state} (${s.tools} tools)`)
804
+ ctx.ui.notify(lines.join('\n'), 'info')
805
+ },
806
+ })
749
807
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * pi's native MCP (pi 0.99 and later): translation of pi-code's server configs into the
3
+ * `McpServerConfig` pi.registerMcpServer() takes.
4
+ */
5
+
6
+ import * as os from 'node:os'
7
+ import type { McpServerConfig } from '@earendil-works/pi-coding-agent'
8
+ import type { McpToolAlias } from '../internal/mcp-alias.js'
9
+ import { type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
10
+ import type { McpPolicy } from './policy.js'
11
+ import { callBudgetMs, type SessionDirs } from './transport.js'
12
+
13
+ export interface NativeServer {
14
+ name: string
15
+ config: McpServerConfig
16
+ }
17
+
18
+ /** A server pi can connect, or why it stays on pi-code's client. */
19
+ export type NativeTranslation = { native: NativeServer } | { reason: string }
20
+
21
+ // pi's server names; its tools become mcp__<name>__<tool>.
22
+ const NATIVE_NAME = /^[A-Za-z0-9_-]+$/
23
+
24
+ /** pi resolves env and header values itself: a leading `!` runs the value as a shell
25
+ * command and `$NAME` / `${NAME}` expand. pi-code has already applied Claude's expansion,
26
+ * so the result is escaped to reach the server as written (`$$` is `$`, `$!` is `!`). */
27
+ function literal(value: string): string {
28
+ return value.replaceAll('$', () => '$$').replace(/^!/, () => '$!')
29
+ }
30
+
31
+ function mapValues(record: Record<string, string>, map: (value: string) => string): Record<string, string> {
32
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, map(value)]))
33
+ }
34
+
35
+ /** Plugin servers register as plugin_<plugin>_<server>, so pi's tool names match
36
+ * Claude's mcp__plugin_<plugin>_<server>__<tool>. */
37
+ function nativeName(name: string, config: ServerConfig): string {
38
+ return config.aliasPrefix?.replace(/^mcp__/, '').replace(/__$/, '') ?? name
39
+ }
40
+
41
+ function stdioConfig(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): McpServerConfig {
42
+ // The variables pi-code's own client exports to a stdio server (transport.ts stdioEnv).
43
+ const env: Record<string, string> = { ...mapValues(config.env ?? {}, fill), CLAUDECODE: '1' }
44
+ if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
45
+ if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
46
+ if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
47
+ if (config.pluginDataDir !== undefined) env.CLAUDE_PLUGIN_DATA = config.pluginDataDir
48
+ const cwd = config.cwd ? fill(config.cwd).replace(/^~(?=\/|$)/, os.homedir()) : undefined
49
+ return {
50
+ command: fill(config.command),
51
+ args: (config.args ?? []).map(fill),
52
+ env: mapValues(env, literal),
53
+ ...(cwd === undefined ? {} : { cwd }),
54
+ }
55
+ }
56
+
57
+ /** An inline bearerToken (expanded) wins over bearerTokenEnv, which names a variable read as is. */
58
+ function bearerToken(config: HttpServerConfig, fill: (value: string) => string, env: NodeJS.ProcessEnv): string | undefined {
59
+ if (config.bearerToken) return fill(config.bearerToken)
60
+ return config.bearerTokenEnv ? env[config.bearerTokenEnv] : undefined
61
+ }
62
+
63
+ function httpConfig(config: HttpServerConfig, fill: (value: string) => string, env: NodeJS.ProcessEnv): McpServerConfig {
64
+ const headers = mapValues(config.headers ?? {}, fill)
65
+ const token = bearerToken(config, fill, env)
66
+ if (token) headers.Authorization = `Bearer ${token}`
67
+ const oauth = config.oauth
68
+ const secret = env.MCP_CLIENT_SECRET
69
+ return {
70
+ url: fill(config.url),
71
+ ...(Object.keys(headers).length > 0 ? { headers: mapValues(headers, literal) } : {}),
72
+ ...(oauth
73
+ ? {
74
+ oauth: {
75
+ ...(oauth.clientId === undefined ? {} : { clientId: oauth.clientId }),
76
+ ...(oauth.clientId !== undefined && secret ? { clientSecret: literal(secret) } : {}),
77
+ ...(oauth.callbackPort === undefined ? {} : { callbackPort: oauth.callbackPort }),
78
+ ...(oauth.scopes === undefined ? {} : { scope: oauth.scopes }),
79
+ },
80
+ }
81
+ : {}),
82
+ }
83
+ }
84
+
85
+ function unsupported(config: ServerConfig): string | undefined {
86
+ if ('url' in config) {
87
+ if (config.type === 'sse' || config.type === 'ws' || config.type === 'websocket') return `${config.type} transport`
88
+ if (config.headersHelper !== undefined) return 'headersHelper'
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ /** The config pi connects for this server, or the reason pi-code keeps it: pi has no SSE
94
+ * or WebSocket transport, no header helper, and accepts only [A-Za-z0-9_-] names. */
95
+ export function toNativeServer(name: string, config: ServerConfig, session?: SessionDirs, env: NodeJS.ProcessEnv = process.env): NativeTranslation {
96
+ const reason = unsupported(config)
97
+ if (reason) return { reason }
98
+ const registered = nativeName(name, config)
99
+ if (!NATIVE_NAME.test(registered)) return { reason: 'server name' }
100
+ const fill = (value: string): string => interpolateEnv(value, env)
101
+ const transport = 'url' in config ? httpConfig(config, fill, env) : stdioConfig(config, fill, session)
102
+ return { native: { name: registered, config: { ...transport, exposure: 'direct', timeout: Math.ceil(callBudgetMs(config) / 1000) } as McpServerConfig } }
103
+ }
104
+
105
+ /** Whether pi connects the servers: its API exists (pi 0.99 and later) and no MCP policy
106
+ * is configured. pi also connects the servers in its own mcp.json files, which Claude's
107
+ * managed-mcp.json and allow and deny lists would not reach, so a policy keeps pi-code's
108
+ * own client for everything. */
109
+ export function nativeMode(api: object, managed: unknown, policy: McpPolicy): boolean {
110
+ if (typeof (api as { registerMcpServer?: unknown }).registerMcpServer !== 'function') return false
111
+ return managed === null && policy.allowed === null && policy.denied.length === 0
112
+ }
113
+
114
+ /** Whether pi's own /mcp is loaded, i.e. its MCP extension was neither disabled
115
+ * (`-builtin:mcp`) nor replaced by another extension's /mcp. */
116
+ export function piMcpRunning(commands: ReadonlyArray<{ name: string; sourceInfo?: { path?: string } }>): boolean {
117
+ return commands.some((command) => command.name === 'mcp' && command.sourceInfo?.path === 'builtin:mcp')
118
+ }
119
+
120
+ /** pi's MCP tools already carry Claude's mcp__<server>__<tool> names, so each is its own alias. */
121
+ export function nativeToolAliases(tools: ReadonlyArray<{ name: string }>): McpToolAlias[] {
122
+ return tools.filter((tool) => tool.name.startsWith('mcp__')).map((tool) => ({ pi: tool.name, claude: tool.name }))
123
+ }
@@ -98,6 +98,15 @@ export function callRequestOptions(wall: number, tuning: ServerCallTuning = {}):
98
98
  return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
99
99
  }
100
100
 
101
+ /** The single timeout a server's calls get where only one can be set (pi's native MCP,
102
+ * whose timeout resets on progress): the idle window, or the wall budget when the idle
103
+ * timeout is disabled or looser than it. */
104
+ export function callBudgetMs(config: ServerConfig): number {
105
+ const wall = callTimeoutMs()
106
+ const idle = idleTimeoutMs(serverCallTuning(config))
107
+ return idle === 0 || idle >= wall ? wall : idle
108
+ }
109
+
101
110
  /** The tuning one server's config yields: its transport kind, and its declared
102
111
  * per-server timeout. Per Claude, timeout values below 1000 are ignored and fall
103
112
  * through to MCP_TOOL_TIMEOUT. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",
@@ -63,10 +63,10 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "^2.5.4",
66
- "@earendil-works/pi-agent-core": "^0.85.1",
67
- "@earendil-works/pi-ai": "^0.85.1",
68
- "@earendil-works/pi-coding-agent": "^0.85.1",
69
- "@earendil-works/pi-tui": "^0.85.1",
66
+ "@earendil-works/pi-agent-core": "^0.99.1",
67
+ "@earendil-works/pi-ai": "^0.99.1",
68
+ "@earendil-works/pi-coding-agent": "^0.99.1",
69
+ "@earendil-works/pi-tui": "^0.99.1",
70
70
  "@types/node": "^26.1.1",
71
71
  "@vitest/coverage-v8": "^5.0.0",
72
72
  "fast-check": "^4.9.0",