pi-code 1.0.62 → 1.0.64
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 +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +46 -24
- package/extensions/git-checkpoint.ts +141 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +14 -10
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +17 -29
- package/extensions/internal/agent-run.ts +1 -1
- package/extensions/internal/bash-rules.ts +1 -2
- package/extensions/internal/claude-tool-names.ts +4 -7
- package/extensions/internal/command-file.ts +2 -3
- package/extensions/internal/external-imports.ts +4 -5
- package/extensions/internal/goal-evaluator.ts +4 -3
- package/extensions/internal/instruction-events.ts +5 -5
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +28 -6
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +51 -41
- package/extensions/internal/plugins.ts +5 -5
- package/extensions/internal/process-tree.ts +39 -0
- package/extensions/internal/project-approval.ts +7 -7
- package/extensions/internal/project-root.ts +13 -13
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/settings-watch.ts +3 -1
- package/extensions/internal/shell-resolve.ts +19 -6
- package/extensions/internal/tool-target.ts +4 -5
- package/extensions/internal/values.ts +20 -4
- package/extensions/mcp/config.ts +11 -4
- package/extensions/mcp/index.ts +78 -19
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +12 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +142 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +11 -6
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +19 -4
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +34 -20
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- package/package.json +1 -1
|
@@ -16,11 +16,10 @@ import * as path from 'node:path'
|
|
|
16
16
|
/** The project root marker. `.git` is a file in worktrees and submodules, a directory
|
|
17
17
|
* in an ordinary clone.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
* project
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* also a marker it could move. */
|
|
19
|
+
* Only `.git`: a package.json marker would make every package of a monorepo its own
|
|
20
|
+
* project (its own memory directory, settings.local.json, CLAUDE_PROJECT_DIR and trust
|
|
21
|
+
* decision), and a repository can add a package.json wherever it likes, so a marker it
|
|
22
|
+
* controls is a marker it can move. Claude's project is the repository. */
|
|
24
23
|
export const ROOT_MARKERS = ['.git']
|
|
25
24
|
|
|
26
25
|
/** Project root at or above `from`, or undefined outside a repository. */
|
|
@@ -32,11 +31,12 @@ export function repoRoot(from: string): string | undefined {
|
|
|
32
31
|
|
|
33
32
|
/** The git checkout at or above `from`, or undefined outside one.
|
|
34
33
|
*
|
|
35
|
-
* Narrower than repoRoot on purpose
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout,
|
|
35
|
+
* which is the right key for shared state (settings.local.json, auto memory) but is a
|
|
36
|
+
* sibling of the worktree, never an ancestor. The upward walks above bound themselves
|
|
37
|
+
* here instead, since a boundary that is not on the path from cwd to / is never reached
|
|
38
|
+
* and the walk would run on to the filesystem root. `.git` cannot be committed into a
|
|
39
|
+
* repository, so it is not a marker the repository can add to move its own key.
|
|
40
40
|
*/
|
|
41
41
|
export function gitRoot(from: string): string | undefined {
|
|
42
42
|
let currentDir = from
|
|
@@ -89,7 +89,7 @@ function statOf(target: string): fs.Stats | null {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function findNearest(cwd: string, relative: string, wantDir: boolean): string | null {
|
|
92
|
-
const boundary =
|
|
92
|
+
const boundary = gitRoot(cwd) ?? cwd
|
|
93
93
|
let currentDir = cwd
|
|
94
94
|
while (true) {
|
|
95
95
|
const candidate = path.join(currentDir, relative)
|
|
@@ -117,7 +117,7 @@ export function findNearestFile(cwd: string, relative: string): string | null {
|
|
|
117
117
|
* matching Claude's "every .claude/<kind> between the working directory and the
|
|
118
118
|
* repository root" discovery where the entry closest to cwd wins a name clash. */
|
|
119
119
|
export function ancestorDirs(cwd: string, relative: string): string[] {
|
|
120
|
-
const boundary =
|
|
120
|
+
const boundary = gitRoot(cwd) ?? cwd
|
|
121
121
|
const found: string[] = []
|
|
122
122
|
let currentDir = cwd
|
|
123
123
|
while (true) {
|
|
@@ -134,7 +134,7 @@ export function ancestorDirs(cwd: string, relative: string): string[] {
|
|
|
134
134
|
/** Every `relative` file between the repository root and cwd, ordered root first,
|
|
135
135
|
* matching Claude's root-down ordering for hierarchy-loaded context. */
|
|
136
136
|
export function ancestorFiles(cwd: string, relative: string): string[] {
|
|
137
|
-
const boundary =
|
|
137
|
+
const boundary = gitRoot(cwd) ?? cwd
|
|
138
138
|
const found: string[] = []
|
|
139
139
|
let currentDir = cwd
|
|
140
140
|
while (true) {
|
|
@@ -52,12 +52,31 @@ export function claudeSettingsChain(cwd: string, home: string, includeProject: b
|
|
|
52
52
|
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
53
53
|
if (!includeProject) return files
|
|
54
54
|
files.push(path.join(cwd, '.claude', 'settings.json'))
|
|
55
|
+
// Compared as the directory the placement rule returned, not re-derived from a
|
|
56
|
+
// joined path: path.join normalizes separators, so a cwd given POSIX-style on
|
|
57
|
+
// Windows would never equal its own joined form and the legacy entry would repeat.
|
|
55
58
|
const localDir = localSettingsDir(cwd, home, platform, owned)
|
|
56
59
|
if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
|
|
57
60
|
files.push(path.join(localDir, '.claude', 'settings.local.json'))
|
|
58
61
|
return files
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/** The settings.local.json the chain reads last, which is also where a setting a
|
|
65
|
+
* command persists (an output-style choice, an MCP consent) must be written for the
|
|
66
|
+
* chain to read it back: a file at any other level is never consulted. */
|
|
67
|
+
export function localSettingsFile(cwd: string, home: string, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string {
|
|
68
|
+
return path.join(localSettingsDir(cwd, home, platform, owned), '.claude', 'settings.local.json')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One settings file as a JSON object, or undefined when missing, unparseable or not
|
|
72
|
+
* an object: the single-file case of the chain, for the user-only settings a
|
|
73
|
+
* repository must not influence (a notification channel, a question timeout, a
|
|
74
|
+
* retention period). */
|
|
75
|
+
export function readSettingsFile(file: string): Record<string, unknown> | undefined {
|
|
76
|
+
const first = readSettingsChain([file]).next()
|
|
77
|
+
return first.done ? undefined : first.value
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
/** Every readable settings object in the chain, in order, so the last one a caller
|
|
62
81
|
* sees for a key is the one that wins. A file that is missing, unparseable, or not a
|
|
63
82
|
* JSON object is skipped: a corrupt settings.json must not end the chain, or the
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from 'node:fs'
|
|
9
|
+
import { parseNumericEnv } from './values.js'
|
|
9
10
|
|
|
10
11
|
// Captured at module load: the poll must run on real time even under a test's
|
|
11
12
|
// fake timers (the stat watcher it replaced lived in libuv and was immune too);
|
|
@@ -29,7 +30,8 @@ function snapshot(file: string): string | undefined {
|
|
|
29
30
|
* small, so re-reading them on the poll is negligible. The interval is
|
|
30
31
|
* env-tunable for tests. */
|
|
31
32
|
export function watchSettingsFiles(files: string[], reload: () => void): () => void {
|
|
32
|
-
const
|
|
33
|
+
const configured = parseNumericEnv(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS)
|
|
34
|
+
const interval = configured !== undefined && configured > 0 ? configured : 2000
|
|
33
35
|
let last = files.map(snapshot)
|
|
34
36
|
const timer = realSetInterval(() => {
|
|
35
37
|
const next = files.map(snapshot)
|
|
@@ -37,11 +37,22 @@ const isFile = (file: string): boolean => {
|
|
|
37
37
|
* Windows spellings on win32, where powershell.exe ships with the OS. */
|
|
38
38
|
const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
|
|
39
39
|
|
|
40
|
-
/** First PowerShell binary found on PATH, or undefined when none is installed.
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** First PowerShell binary found on PATH, or undefined when none is installed.
|
|
41
|
+
*
|
|
42
|
+
* A PATH entry that is the launch directory, or project tooling below it, is skipped
|
|
43
|
+
* for the same reason resolveGitBash skips one: a repository that ships `pwsh.exe`
|
|
44
|
+
* must not become the shell its own hooks and spans run through. Go made this the
|
|
45
|
+
* default in 1.19 (`os/exec` refuses a program resolved "relative to the current
|
|
46
|
+
* directory", returning ErrDot), and Windows offers NoDefaultCurrentDirectoryInExePath
|
|
47
|
+
* for it; node honors neither (nodejs/node#46264), so the check belongs here. */
|
|
48
|
+
export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd()): string | undefined {
|
|
49
|
+
// `env.Path` as well as `env.PATH`, matching resolveGitBash: process.env is
|
|
50
|
+
// case-insensitive on Windows, but an env object handed in by a caller or a test is
|
|
51
|
+
// whatever spelling it was built with, and the two resolvers must read it alike.
|
|
52
|
+
const dirs = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)
|
|
43
53
|
for (const candidate of powershellCandidates(platform)) {
|
|
44
54
|
for (const dir of dirs) {
|
|
55
|
+
if (isProjectTooling(dir, cwd)) continue
|
|
45
56
|
const full = path.join(dir, candidate)
|
|
46
57
|
try {
|
|
47
58
|
fs.accessSync(full, fs.constants.X_OK)
|
|
@@ -59,7 +70,9 @@ export function resolvePowershellBinary(platform: string = process.platform, env
|
|
|
59
70
|
function isProjectTooling(dir: string, cwd: string): boolean {
|
|
60
71
|
const relative = path.relative(cwd, dir)
|
|
61
72
|
if (relative === '') return true
|
|
62
|
-
|
|
73
|
+
// Lexical containment: `..tools/...` is a directory inside cwd, only `..` itself or
|
|
74
|
+
// `../...` leaves it (the shape claude-rules.ts's containment check spells out).
|
|
75
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false
|
|
63
76
|
return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -111,11 +124,11 @@ const powershellShell = (file: string): ResolvedShell => ({
|
|
|
111
124
|
*/
|
|
112
125
|
export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
|
|
113
126
|
if (preferred === 'powershell') {
|
|
114
|
-
const powershell = resolvePowershellBinary(platform, env)
|
|
127
|
+
const powershell = resolvePowershellBinary(platform, env, cwd)
|
|
115
128
|
if (powershell) return powershellShell(powershell)
|
|
116
129
|
}
|
|
117
130
|
const bash = bashBinary(platform, env, cwd, installRoots)
|
|
118
131
|
if (bash) return bashShell(bash)
|
|
119
|
-
const powershell = resolvePowershellBinary(platform, env)
|
|
132
|
+
const powershell = resolvePowershellBinary(platform, env, cwd)
|
|
120
133
|
return powershell ? powershellShell(powershell) : undefined
|
|
121
134
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which file a tool call touched.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the alias. One reader, one place to be wrong.
|
|
4
|
+
* pi's read, edit and write tools accept `file_path` as an alias for `path`, so every
|
|
5
|
+
* reader of a file tool's target must accept both, and a handler reading only `path`
|
|
6
|
+
* does nothing for a model that used the alias. This is the one reader (claude-rules,
|
|
7
|
+
* context-imports and the command path-scope guard all go through it).
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
/** The tools that name a file pi-code acts on. */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* seventeen files.
|
|
2
|
+
* The small shared shapes: an error's message, a plain-object check, whether a path
|
|
3
|
+
* is a directory, the text of a message content, a regex escape, a numeric env
|
|
4
|
+
* value. One copy each; a private copy in an extension is the drift to look for.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import * as fs from 'node:fs'
|
|
@@ -36,3 +35,20 @@ export function contentText(content: unknown, separator = ''): string {
|
|
|
36
35
|
.map((part) => part.text)
|
|
37
36
|
.join(separator)
|
|
38
37
|
}
|
|
38
|
+
|
|
39
|
+
/** Escape a string for literal use inside a RegExp. One copy: five private ones had
|
|
40
|
+
* the same body and would have drifted the first time one of them was fixed. */
|
|
41
|
+
export function escapeRegExp(text: string): string {
|
|
42
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A numeric environment value, or undefined when blank or not a number. Accepts the
|
|
46
|
+
* spellings docs/mcp.md promises (`2e3`, `64_000`); the caller decides the range and
|
|
47
|
+
* whether fractions are meaningful, so no flooring here. */
|
|
48
|
+
export function parseNumericEnv(raw: string | undefined): number | undefined {
|
|
49
|
+
if (raw === undefined) return undefined
|
|
50
|
+
const cleaned = raw.replaceAll('_', '')
|
|
51
|
+
if (cleaned.trim() === '') return undefined
|
|
52
|
+
const value = Number(cleaned)
|
|
53
|
+
return Number.isFinite(value) ? value : undefined
|
|
54
|
+
}
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
import * as fs from 'node:fs'
|
|
7
7
|
import * as os from 'node:os'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
9
10
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
11
|
import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
11
12
|
import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
|
|
12
13
|
import { findNearestFile } from '../internal/project-root.js'
|
|
14
|
+
import { errorMessage } from '../internal/values.js'
|
|
13
15
|
|
|
14
16
|
export interface StdioServerConfig {
|
|
15
17
|
type?: 'stdio'
|
|
@@ -91,7 +93,9 @@ function claudeJsonPath(home: string): string {
|
|
|
91
93
|
/** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
|
|
92
94
|
* tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
|
|
93
95
|
export function userConfigPaths(home: string): string[] {
|
|
94
|
-
|
|
96
|
+
// mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
|
|
97
|
+
// other agent-directory readers (trust store, OAuth tokens) already follow it.
|
|
98
|
+
return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
@@ -107,8 +111,10 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
107
111
|
try {
|
|
108
112
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
109
113
|
Object.assign(servers, parsed.mcpServers ?? {})
|
|
110
|
-
} catch {
|
|
111
|
-
// missing
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// A missing file is the normal case. A present file that does not parse is
|
|
116
|
+
// not: one trailing comma silently disabled every server in it.
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${file}: ${errorMessage(error)}`)
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
return servers
|
|
@@ -132,7 +138,8 @@ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string,
|
|
|
132
138
|
try {
|
|
133
139
|
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
134
140
|
return claudeJson.projects?.[cwd] ?? {}
|
|
135
|
-
} catch {
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${claudeJsonPath(home)}: ${errorMessage(error)}`)
|
|
136
143
|
return {}
|
|
137
144
|
}
|
|
138
145
|
}
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -49,12 +49,11 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
|
|
|
49
49
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
50
50
|
import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
51
51
|
import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
|
|
52
|
-
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
|
|
52
|
+
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
|
|
53
53
|
|
|
54
54
|
export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
|
|
55
55
|
// Re-exports for consumers: the module split keeps the extension's public surface
|
|
56
|
-
// (imported by the test suite) reachable from this entry point unchanged.
|
|
57
|
-
// settings path helpers now live in the shared internal module.
|
|
56
|
+
// (imported by the test suite) reachable from this entry point unchanged.
|
|
58
57
|
export type { HttpServerConfig, ServerConfig, StdioServerConfig } from './config.js'
|
|
59
58
|
export { expandCwd, interpolateEnv, loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, userConfigPaths, warnOnTypelessUrl } from './config.js'
|
|
60
59
|
export type { McpToolInfo } from './listing.js'
|
|
@@ -85,6 +84,12 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
|
|
|
85
84
|
|
|
86
85
|
export default async function mcpExtension(pi: ExtensionAPI) {
|
|
87
86
|
const clients = new Map<string, Client>()
|
|
87
|
+
// Names with a connect in flight. A name enters `clients` only once its connect
|
|
88
|
+
// resolves, and shutdown clears that map without awaiting anything in flight, so
|
|
89
|
+
// without this set a session switch during a slow connect (or a backoff sleep) let
|
|
90
|
+
// the next session_start connect the same name again; the first client then resolved
|
|
91
|
+
// into an entry the second overwrote and was never closed.
|
|
92
|
+
const connecting = new Set<string>()
|
|
88
93
|
// The session's OAuth UI seams, captured at session_start. The reconnect paths below
|
|
89
94
|
// run outside that handler, and passing undefined there made an INTERACTIVE session
|
|
90
95
|
// report the headless "cannot log in" advice on a re-auth it could actually perform.
|
|
@@ -99,6 +104,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
99
104
|
// Shutdown closes clients while they are still in the map; the onclose handlers
|
|
100
105
|
// must not schedule reconnects for that deliberate teardown.
|
|
101
106
|
let shuttingDown = false
|
|
107
|
+
// Bumped by every session_start and session_shutdown. A late-connecting server's
|
|
108
|
+
// deferred summary captured the ctx of the session that started it; pi disposes that
|
|
109
|
+
// ctx on /new, /resume, /fork and reload (any use of it throws), so the publish must
|
|
110
|
+
// check it still belongs to the live session before touching ctx.
|
|
111
|
+
let sessionGeneration = 0
|
|
102
112
|
const callTuning = (name: string): ServerCallTuning => {
|
|
103
113
|
const config = serverConfigs.get(name)
|
|
104
114
|
return config ? serverCallTuning(config) : {}
|
|
@@ -189,7 +199,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
189
199
|
async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
|
|
190
200
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
191
201
|
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
|
|
192
|
-
if (shuttingDown || clients.has(name)) return
|
|
202
|
+
if (shuttingDown || clients.has(name) || connecting.has(name)) return
|
|
193
203
|
await connectServers({ [name]: config }, sessionAuthUi, true)
|
|
194
204
|
if (clients.has(name)) return
|
|
195
205
|
}
|
|
@@ -410,10 +420,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
410
420
|
console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
|
|
411
421
|
continue
|
|
412
422
|
}
|
|
423
|
+
// The one guard for every connect path (session_start scopes, backoff, auth
|
|
424
|
+
// reconnect): a name still connecting from the previous session is adopted when
|
|
425
|
+
// that connect resolves, not connected again.
|
|
426
|
+
if (connecting.has(name)) continue
|
|
413
427
|
// Seed in config order before connecting: parallel connects settle in completion
|
|
414
428
|
// order, and /mcp plus the session summary iterate the map's insertion order.
|
|
415
429
|
status.set(name, { state: 'connecting', tools: 0 })
|
|
416
430
|
serverConfigs.set(name, config)
|
|
431
|
+
connecting.add(name)
|
|
417
432
|
pending.push([name, config])
|
|
418
433
|
}
|
|
419
434
|
await Promise.all(
|
|
@@ -423,6 +438,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
423
438
|
// First connections retry transient failures (Claude: up to three times for
|
|
424
439
|
// HTTP/SSE); the backoff reconnect below carries its own schedule instead.
|
|
425
440
|
const client = noRetry ? await connect(name, config, authUi, sessionDirs) : await connectWithRetries(name, config, authUi, sessionDirs)
|
|
441
|
+
// A session switch resets shuttingDown before this resolves and adopts the
|
|
442
|
+
// client; still set, pi is exiting and the shutdown handler already closed
|
|
443
|
+
// every client it could see, so this late one must close itself.
|
|
444
|
+
if (shuttingDown) {
|
|
445
|
+
await withTimeout(client.close(), 3000, 'close').catch(() => {})
|
|
446
|
+
status.delete(name)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
426
449
|
clients.set(name, client)
|
|
427
450
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
428
451
|
registerTools(name, config, tools)
|
|
@@ -460,6 +483,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
460
483
|
clients.delete(name)
|
|
461
484
|
void leaked.close().catch(() => {})
|
|
462
485
|
}
|
|
486
|
+
} finally {
|
|
487
|
+
connecting.delete(name)
|
|
463
488
|
}
|
|
464
489
|
}),
|
|
465
490
|
)
|
|
@@ -563,6 +588,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
563
588
|
if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
|
|
564
589
|
}
|
|
565
590
|
|
|
591
|
+
/** Publishes the current alias set and the connection banner. `'connecting'`
|
|
592
|
+
* servers are excluded from both connected and failed counts: they are neither,
|
|
593
|
+
* and while the non-blocking startup race below is still in flight some legitimately
|
|
594
|
+
* are. Called once right after startup (whether or not every server settled by
|
|
595
|
+
* then) and again when a still-connecting tail finishes, so a server that connects
|
|
596
|
+
* late still gets its aliases published and counted. */
|
|
597
|
+
function publishConnectionSummary(ctx: ExtensionContext): void {
|
|
598
|
+
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
599
|
+
const connected = [...status.values()].filter((s) => s.state === 'connected')
|
|
600
|
+
const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
|
|
601
|
+
if (connected.length > 0 || failed.length > 0) {
|
|
602
|
+
const total = connected.reduce((sum, s) => sum + s.tools, 0)
|
|
603
|
+
const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
|
|
604
|
+
ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
566
608
|
pi.on('session_start', async (_event, ctx) => {
|
|
567
609
|
// Reset the status map so /mcp and the banner reflect only this session's config: a
|
|
568
610
|
// server present last session but not this one must not linger as "connected". The
|
|
@@ -570,6 +612,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
570
612
|
// withdrawn tool keeps its registration and surfaces the server's own error), which
|
|
571
613
|
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
572
614
|
status.clear()
|
|
615
|
+
const generation = ++sessionGeneration
|
|
573
616
|
// A same-process session switch (/new, /resume) shut the last session down;
|
|
574
617
|
// this one may reconnect again. projectConnected guards against connecting the project
|
|
575
618
|
// scope twice within one session, so it belongs to the session that set it: leaving it
|
|
@@ -579,7 +622,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
579
622
|
projectConnected = false
|
|
580
623
|
// Claude answers roots/list with the session's launch directory and exports the
|
|
581
624
|
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
582
|
-
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
|
|
625
|
+
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
|
|
583
626
|
const authUi = authUiFor(ctx)
|
|
584
627
|
sessionAuthUi = authUi
|
|
585
628
|
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
@@ -592,20 +635,35 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
592
635
|
// file leaves the normal scopes untouched; a present but corrupt file fails closed to an
|
|
593
636
|
// empty set (see loadManagedMcpServers).
|
|
594
637
|
const managed = loadManagedMcpServers()
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
638
|
+
const connecting = managed !== null ? connectManagedExclusive(managed, policy, authUi) : connectNormalScopes(ctx, policy, authUi)
|
|
639
|
+
// Claude: "MCP startup is non-blocking by default: servers connect in the
|
|
640
|
+
// background and their tools become available as they finish." A slow or
|
|
641
|
+
// unreachable server no longer costs the whole session's startup its own
|
|
642
|
+
// MCP_TIMEOUT x retries; alwaysLoad-style forced waiting and the ToolSearch/
|
|
643
|
+
// WaitForMcpServers mechanism that lets the model itself wait on a pending
|
|
644
|
+
// server's tools are not implemented, so a call to a tool that has not
|
|
645
|
+
// registered yet still just finds no such tool, same as before it connected.
|
|
646
|
+
//
|
|
647
|
+
// Claude also states non-interactive mode "waits for still-pending servers
|
|
648
|
+
// before the first turn regardless of this variable", because a -p run has no
|
|
649
|
+
// later turn to react to late-arriving tools; a headless ctx (no UI) keeps the
|
|
650
|
+
// prior fully-blocking wait for the same reason.
|
|
651
|
+
const settled = !ctx.hasUI
|
|
652
|
+
? await connecting.then(() => true)
|
|
653
|
+
: await withTimeout(connecting, mcpConnectTimeoutMs(), 'mcp startup').then(
|
|
654
|
+
() => true,
|
|
655
|
+
() => false,
|
|
656
|
+
)
|
|
657
|
+
publishConnectionSummary(ctx)
|
|
658
|
+
if (!settled) {
|
|
659
|
+
// The rejection path exists for the throw a disposed ctx would raise if the
|
|
660
|
+
// generation check were ever wrong: from a bare `.then` it would be an unhandled
|
|
661
|
+
// rejection, and pi installs no handler, so Node would take the process down.
|
|
662
|
+
void connecting
|
|
663
|
+
.then(() => {
|
|
664
|
+
if (generation === sessionGeneration) publishConnectionSummary(ctx)
|
|
665
|
+
})
|
|
666
|
+
.catch(() => {})
|
|
609
667
|
}
|
|
610
668
|
})
|
|
611
669
|
|
|
@@ -613,6 +671,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
613
671
|
// Closing fires each client's onclose while it is still in the map; the flag
|
|
614
672
|
// stops those handlers (and any in-flight backoff loop) from reconnecting.
|
|
615
673
|
shuttingDown = true
|
|
674
|
+
sessionGeneration++
|
|
616
675
|
// Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
|
|
617
676
|
await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
|
|
618
677
|
// Drop the closed clients and their status now rather than waiting on each client's
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs'
|
|
8
8
|
import * as path from 'node:path'
|
|
9
|
-
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
9
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
11
|
-
import {
|
|
12
|
-
import { errorMessage } from '../internal/values.js'
|
|
10
|
+
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
+
import { errorMessage, escapeRegExp } from '../internal/values.js'
|
|
13
12
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
14
13
|
|
|
15
14
|
export interface ProjectServerPolicy {
|
|
@@ -38,11 +37,17 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// The files come from the one settings chain, so placement follows Claude's rules
|
|
41
|
+
// everywhere: the project's settings.json is read from cwd only (never an ancestor),
|
|
42
|
+
// and settings.local.json from the main checkout, with the chain's legacy cwd copy
|
|
43
|
+
// read too. Resolving them by nearest-file here gave a subdirectory session an
|
|
44
|
+
// ancestor's project file and a worktree session its own local file instead.
|
|
45
|
+
const [userFile, projectFile, ...localFiles] = claudeSettingsChain(cwd, home, true)
|
|
46
|
+
const userSettings = read(userFile)
|
|
47
|
+
const projectSettings = read(projectFile)
|
|
48
|
+
const localSettings = localFiles.map(read)
|
|
49
|
+
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...localSettings.flatMap((settings) => names(settings.disabledMcpjsonServers))])
|
|
50
|
+
const consentSources = projectApproved ? [userSettings, ...localSettings] : [userSettings]
|
|
46
51
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
47
52
|
const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
|
|
48
53
|
return { disabled, consented, consentAll }
|
|
@@ -127,10 +132,7 @@ export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = ma
|
|
|
127
132
|
|
|
128
133
|
/** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
|
|
129
134
|
function wildcardRegExp(pattern: string): RegExp {
|
|
130
|
-
const source = pattern
|
|
131
|
-
.split('*')
|
|
132
|
-
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
133
|
-
.join('.*')
|
|
135
|
+
const source = pattern.split('*').map(escapeRegExp).join('.*')
|
|
134
136
|
return new RegExp(`^${source}$`)
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -18,6 +18,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
|
|
|
18
18
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
19
19
|
import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
|
|
20
20
|
import { resolveShell } from '../internal/shell-resolve.js'
|
|
21
|
+
import { parseNumericEnv } from '../internal/values.js'
|
|
21
22
|
import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
|
|
22
23
|
import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
|
|
23
24
|
|
|
@@ -36,25 +37,24 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
|
|
|
36
37
|
|
|
37
38
|
/** Claude's numeric env vars accept scientific notation and digit-separator spellings
|
|
38
39
|
* (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
|
|
39
|
-
function parseNumericEnv(raw: string): number | undefined {
|
|
40
|
-
const cleaned = raw.replaceAll('_', '')
|
|
41
|
-
if (cleaned.trim() === '') return undefined
|
|
42
|
-
const value = Number(cleaned)
|
|
43
|
-
return Number.isFinite(value) ? Math.floor(value) : undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
47
41
|
function envTimeout(name: string, fallback: number): number {
|
|
48
42
|
const raw = process.env[name]
|
|
49
43
|
if (raw === undefined) return fallback
|
|
50
44
|
const value = parseNumericEnv(raw)
|
|
51
|
-
return value !== undefined && value > 0 ? value : fallback
|
|
45
|
+
return value !== undefined && value > 0 ? Math.floor(value) : fallback
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
55
49
|
export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
56
50
|
export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
57
51
|
|
|
52
|
+
// Claude: "How long blocking MCP startup waits, in milliseconds, for the connection
|
|
53
|
+
// batch before snapshotting the tool list (default: 5000)." Distinct from MCP_TIMEOUT,
|
|
54
|
+
// which bounds one server's own connect attempt, not the whole startup batch.
|
|
55
|
+
const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 5000
|
|
56
|
+
export const mcpConnectTimeoutMs = (): number => envTimeout('MCP_CONNECT_TIMEOUT_MS', DEFAULT_MCP_CONNECT_TIMEOUT_MS)
|
|
57
|
+
|
|
58
58
|
/** Per-server inputs to the idle-window choice: the transport kind picks the default
|
|
59
59
|
* tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
|
|
60
60
|
export interface ServerCallTuning {
|
|
@@ -137,6 +137,9 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
|
137
137
|
export interface SessionDirs {
|
|
138
138
|
projectDir: string
|
|
139
139
|
launchDir: string
|
|
140
|
+
/** Claude: "An MCP server subprocess retains the ID it was spawned with", so this
|
|
141
|
+
* is captured once here at connect time rather than re-read per call. */
|
|
142
|
+
sessionId?: string
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** A client that, like Claude, declares the roots capability and answers roots/list
|
|
@@ -191,6 +194,7 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
|
|
|
191
194
|
const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
|
|
192
195
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
193
196
|
if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
|
|
197
|
+
if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
|
|
194
198
|
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
195
199
|
// The data dir is "created on first reference"; handing the path to a server is that
|
|
196
200
|
// reference, so the server does not have to mkdir it before using it.
|
package/extensions/memory.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
|
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
|
-
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
15
|
+
import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
17
|
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
18
18
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
@@ -63,7 +63,7 @@ function memoryProject(cwd: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function memoryDir(cwd: string): string {
|
|
66
|
-
return path.join(
|
|
66
|
+
return path.join(getAgentDir(), 'memory', projectSlug(memoryProject(cwd)))
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** The store location, honoring an `autoMemoryDirectory` override. Claude requires
|
|
@@ -142,7 +142,7 @@ export function stripNonLoaded(text: string): string {
|
|
|
142
142
|
export function migrateLegacyStore(cwd: string): void {
|
|
143
143
|
const current = memoryDir(cwd)
|
|
144
144
|
if (fs.existsSync(current)) return
|
|
145
|
-
const base = path.join(
|
|
145
|
+
const base = path.join(getAgentDir(), 'memory')
|
|
146
146
|
// projectSlug(cwd) differs from current only for a subdirectory session (current is
|
|
147
147
|
// keyed on the repo root); for a repo-root session it equals current and is skipped.
|
|
148
148
|
const candidates = [path.join(base, projectSlug(cwd)), path.join(base, legacySlug(cwd))]
|
|
@@ -177,7 +177,7 @@ export function indexWouldOverflow(index: string, name: string, description: str
|
|
|
177
177
|
/** Where the index stands against the read limits, measured on the loaded content
|
|
178
178
|
* (frontmatter and comments stripped): 'over' past either bound, 'near' within
|
|
179
179
|
* 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
|
|
180
|
-
|
|
180
|
+
function indexReadState(index: string): 'ok' | 'near' | 'over' {
|
|
181
181
|
const loaded = stripNonLoaded(index)
|
|
182
182
|
const lines = loaded.split('\n').length
|
|
183
183
|
const bytes = Buffer.byteLength(loaded, 'utf-8')
|
package/extensions/notify.ts
CHANGED
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { execFile } from 'node:child_process'
|
|
19
|
-
import * as fs from 'node:fs'
|
|
20
19
|
import * as os from 'node:os'
|
|
21
20
|
import * as path from 'node:path'
|
|
22
21
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
23
22
|
|
|
24
23
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
24
|
+
import { readSettingsFile } from './internal/settings-chain.js'
|
|
25
25
|
|
|
26
26
|
/** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
|
|
27
27
|
export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
|
|
@@ -56,12 +56,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
|
|
|
56
56
|
* preference, so only user scope is read; a checked-out repo does not get to silence
|
|
57
57
|
* or change your notifications. */
|
|
58
58
|
function readPreferredNotifChannel(home: string): unknown {
|
|
59
|
-
|
|
60
|
-
const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
61
|
-
return settings?.preferredNotifChannel
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined
|
|
64
|
-
}
|
|
59
|
+
return readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.preferredNotifChannel
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
function windowsToastScript(title: string, body: string): string {
|