pi-code 1.0.72 → 1.0.74
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/extensions/claude-rules.ts +5 -2
- package/extensions/commands.ts +4 -4
- package/extensions/context-imports.ts +12 -5
- package/extensions/hooks/index.ts +2 -2
- package/extensions/init.ts +2 -2
- package/extensions/internal/mcp-oauth.ts +13 -0
- package/extensions/internal/project-root.ts +23 -0
- package/extensions/mcp/index.ts +47 -23
- package/extensions/mcp/mapping.ts +15 -1
- package/extensions/mcp/transport.ts +15 -0
- package/extensions/status-line.ts +2 -2
- package/extensions/subagent/agents.ts +7 -2
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ import { publishInstructionLoad } from './internal/instruction-events.js'
|
|
|
27
27
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
28
28
|
import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
|
|
29
29
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
30
|
-
import { findNearestDir } from './internal/project-root.js'
|
|
30
|
+
import { findNearestDir, sameLocation } from './internal/project-root.js'
|
|
31
31
|
import { stripBlockComments } from './internal/strip-comments.js'
|
|
32
32
|
import { fileToolTarget } from './internal/tool-target.js'
|
|
33
33
|
|
|
@@ -303,7 +303,10 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
|
303
303
|
globalRules = readRules(globalRulesDir, isExcluded)
|
|
304
304
|
// Nearest at-or-above cwd, so a subdirectory session still reads the rules the
|
|
305
305
|
// approval walk gated on.
|
|
306
|
-
|
|
306
|
+
// Not the user's own rules dir: from $HOME (or under a dotfiles repo rooted there)
|
|
307
|
+
// the nearest one is ~/.claude/rules, which the global load above already read.
|
|
308
|
+
const nearestRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
|
|
309
|
+
const projectRulesDir = nearestRulesDir !== null && sameLocation(nearestRulesDir, globalRulesDir) ? null : nearestRulesDir
|
|
307
310
|
projectRules = projectRulesDir ? readRules(projectRulesDir, isExcluded) : EMPTY_RULES
|
|
308
311
|
|
|
309
312
|
// Global globs are relative to cwd; project globs to the project root (the dir
|
package/extensions/commands.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { matchesPathRules } from './internal/path-rules.js'
|
|
|
55
55
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
56
56
|
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
57
57
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
58
|
-
import { ancestorDirs,
|
|
58
|
+
import { ancestorDirs, checkoutRoot } from './internal/project-root.js'
|
|
59
59
|
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
|
|
60
60
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
61
61
|
import { fileToolTarget } from './internal/tool-target.js'
|
|
@@ -206,7 +206,7 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
|
|
|
206
206
|
// low|medium|high|xhigh|max, and an unset one stays literal in the body.
|
|
207
207
|
CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
|
|
208
208
|
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
209
|
-
CLAUDE_PROJECT_DIR:
|
|
209
|
+
CLAUDE_PROJECT_DIR: checkoutRoot(ctx.cwd),
|
|
210
210
|
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
211
211
|
CLAUDE_PLUGIN_DATA: plugin?.dataDir,
|
|
212
212
|
}
|
|
@@ -228,7 +228,7 @@ interface SpanRunner {
|
|
|
228
228
|
* from running.
|
|
229
229
|
*/
|
|
230
230
|
export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, args: string, ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin, options?: { allowShell?: boolean }): Promise<string> {
|
|
231
|
-
const projectRoot =
|
|
231
|
+
const projectRoot = checkoutRoot(ctx.cwd)
|
|
232
232
|
const vars = commandVars(ctx, filePath, plugin)
|
|
233
233
|
const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
|
|
234
234
|
// `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
|
|
@@ -415,7 +415,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
415
415
|
// pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
|
|
416
416
|
// handles both, and the paired guard in hooks/matcher.ts reads both too.
|
|
417
417
|
const filePath = fileToolTarget(event) ?? ''
|
|
418
|
-
const anchors = { cwd: ctx.cwd, projectRoot:
|
|
418
|
+
const anchors = { cwd: ctx.cwd, projectRoot: checkoutRoot(ctx.cwd), home: os.homedir() }
|
|
419
419
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
420
420
|
return {
|
|
421
421
|
block: true,
|
|
@@ -76,7 +76,7 @@ import { managedSettingsPath, readManagedSettings } from './internal/managed-set
|
|
|
76
76
|
import { capForContext, sliceBytes } from './internal/output-guard.js'
|
|
77
77
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
78
78
|
import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
79
|
-
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
79
|
+
import { ancestorFiles, checkoutRoot, findNearestFile, repoRoot, sameLocation } from './internal/project-root.js'
|
|
80
80
|
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
81
81
|
import { statToken } from './internal/stat-token.js'
|
|
82
82
|
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
@@ -293,7 +293,10 @@ export function rootsForImporter(importer: string, home: string, cwd: string, ex
|
|
|
293
293
|
// granting the importer's own directory. pi also loads home-level context
|
|
294
294
|
// files (~/AGENTS.md) that sit outside the config roots; their directory is
|
|
295
295
|
// $HOME, and allowing it would let @.ssh/... read into every session's prompt.
|
|
296
|
-
|
|
296
|
+
// The checkout the session runs in, and the main checkout too: in a worktree they are
|
|
297
|
+
// siblings, and the checkout's own files (docs/, a sibling CLAUDE.md) are as much the
|
|
298
|
+
// project as the shared root a CLAUDE.local.md may live at.
|
|
299
|
+
return realRoots([cwd, checkoutRoot(cwd), repoRoot(cwd) ?? cwd])
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
/** Claude's env gate for loading memory files from --add-dir directories. */
|
|
@@ -972,7 +975,11 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
972
975
|
// ./.claude/CLAUDE.md (nearest at or above cwd) is repo-controlled too, so both
|
|
973
976
|
// ride the one approval decision.
|
|
974
977
|
const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
|
|
975
|
-
const
|
|
978
|
+
const nearestDotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
979
|
+
// From $HOME (or under a dotfiles repo rooted there) the nearest one is the user's own
|
|
980
|
+
// ~/.claude/CLAUDE.md, already loaded above as User memory: not a project file, so no
|
|
981
|
+
// second block and nothing to approve.
|
|
982
|
+
const dotClaudeMd = nearestDotClaudeMd !== null && sameLocation(nearestDotClaudeMd, userClaudeMd) ? null : nearestDotClaudeMd
|
|
976
983
|
if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
|
|
977
984
|
|
|
978
985
|
for (const candidate of candidates) {
|
|
@@ -1029,7 +1036,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
1029
1036
|
|
|
1030
1037
|
if (envCache?.cwd !== cwd) {
|
|
1031
1038
|
const managedNow = readManagedSettings()
|
|
1032
|
-
envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot:
|
|
1039
|
+
envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: checkoutRoot(cwd) }
|
|
1033
1040
|
}
|
|
1034
1041
|
const { managed, excludeGlobs, projectRoot } = envCache
|
|
1035
1042
|
// CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
|
|
@@ -1136,7 +1143,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
1136
1143
|
if (touchedDir === undefined) return
|
|
1137
1144
|
|
|
1138
1145
|
const home = os.homedir()
|
|
1139
|
-
const projectRoot =
|
|
1146
|
+
const projectRoot = checkoutRoot(ctx.cwd)
|
|
1140
1147
|
const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
|
|
1141
1148
|
const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
|
|
1142
1149
|
const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
|
|
@@ -104,7 +104,7 @@ import { resolveModelOverride } from '../internal/model-lookup.js'
|
|
|
104
104
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
105
105
|
import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
|
|
106
106
|
import { approvalRecheck, isProjectApproved } from '../internal/project-approval.js'
|
|
107
|
-
import {
|
|
107
|
+
import { checkoutRoot } from '../internal/project-root.js'
|
|
108
108
|
import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
109
109
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
110
110
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
@@ -457,7 +457,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
457
457
|
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
458
458
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
459
459
|
// subdirectory session too.
|
|
460
|
-
projectDir =
|
|
460
|
+
projectDir = checkoutRoot(ctx.cwd)
|
|
461
461
|
resolveConfig(ctx.cwd, trusted)
|
|
462
462
|
// Claude picks up direct settings edits mid-session via a file watcher. The reload
|
|
463
463
|
// closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
|
package/extensions/init.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { CONTEXT_FILE_CANDIDATES }
|
|
|
23
23
|
|
|
24
24
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { checkoutRoot } from './internal/project-root.js'
|
|
27
27
|
|
|
28
28
|
function statOf(target: string): fs.Stats | undefined {
|
|
29
29
|
try {
|
|
@@ -73,7 +73,7 @@ export default function initExtension(pi: ExtensionAPI) {
|
|
|
73
73
|
pi.registerCommand('init', {
|
|
74
74
|
description: 'Analyze the codebase and create or improve the project context file (AGENTS.md)',
|
|
75
75
|
handler: async (_args, ctx) => {
|
|
76
|
-
const root =
|
|
76
|
+
const root = checkoutRoot(ctx.cwd)
|
|
77
77
|
const existing = findExistingContextFile(root)
|
|
78
78
|
const cursorRules = statOf(path.join(root, '.cursor', 'rules'))?.isDirectory() === true || statOf(path.join(root, '.cursorrules'))?.isFile() === true
|
|
79
79
|
const copilotRules = statOf(path.join(root, '.github', 'copilot-instructions.md'))?.isFile() === true
|
|
@@ -188,6 +188,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
188
188
|
return this.data.tokens !== undefined
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/** Called by the SDK when the server rejects what is stored: `invalid_grant` for an expired
|
|
192
|
+
* or revoked refresh token, `invalid_client` for a forgotten registration. It restarts
|
|
193
|
+
* authorization once this returns, which reaches the login prompt. Without it the
|
|
194
|
+
* rejection propagated and the dead credentials stayed on disk, so the server failed the
|
|
195
|
+
* same way on every connect. Discovery state is not stored, and the redirect port is kept
|
|
196
|
+
* so a re-login can still bind the port the client was registered with. */
|
|
197
|
+
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
|
|
198
|
+
if (scope === 'all' || scope === 'client') delete this.data.client
|
|
199
|
+
if (scope === 'all' || scope === 'tokens') delete this.data.tokens
|
|
200
|
+
if (scope === 'all' || scope === 'verifier') delete this.data.verifier
|
|
201
|
+
this.persist()
|
|
202
|
+
}
|
|
203
|
+
|
|
191
204
|
/** The CSRF token the SDK adds to the authorization URL as `state`; waitForAuthCode
|
|
192
205
|
* verifies the redirect echoes exactly this value. */
|
|
193
206
|
state(): string {
|
|
@@ -29,6 +29,29 @@ export function repoRoot(from: string): string | undefined {
|
|
|
29
29
|
return mainCheckout(root)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** The root of the checkout a session runs in, or `from` itself outside one. Claude's
|
|
33
|
+
* `CLAUDE_PROJECT_DIR` is "the project root where the session started", and `/`-rooted path
|
|
34
|
+
* rules anchor there. In a worktree that is the worktree, not the main checkout repoRoot
|
|
35
|
+
* resolves to: right for shared state (settings.local.json, auto memory), wrong here, where
|
|
36
|
+
* a hook script or a rule anchor must land in the tree the session is editing. */
|
|
37
|
+
export function checkoutRoot(from: string): string {
|
|
38
|
+
return gitRoot(from) ?? from
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether two paths are the same file or directory once symlinks resolve. A session at
|
|
42
|
+
* `$HOME` finds the user's own `~/.claude/{rules,agents,CLAUDE.md}` as its "project" ones;
|
|
43
|
+
* comparing by realpath also catches a stow-style symlinked `~/.claude`. */
|
|
44
|
+
export function sameLocation(a: string, b: string): boolean {
|
|
45
|
+
const resolve = (target: string): string => {
|
|
46
|
+
try {
|
|
47
|
+
return fs.realpathSync(target)
|
|
48
|
+
} catch {
|
|
49
|
+
return path.resolve(target)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return resolve(a) === resolve(b)
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
/** The git checkout at or above `from`, or undefined outside one.
|
|
33
56
|
*
|
|
34
57
|
* Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout,
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -42,14 +42,14 @@ import { setMcpToolCaller } from '../internal/mcp-call.js'
|
|
|
42
42
|
import { capForContext } from '../internal/output-guard.js'
|
|
43
43
|
import { installedPlugins } from '../internal/plugins.js'
|
|
44
44
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
45
|
-
import {
|
|
45
|
+
import { checkoutRoot } from '../internal/project-root.js'
|
|
46
46
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
47
47
|
import { errorMessage } from '../internal/values.js'
|
|
48
48
|
import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
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, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
|
|
52
|
+
import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isConnectionLost, 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
|
|
@@ -189,6 +189,19 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
189
189
|
return count
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/** A client that is gone: flip the status and free the name so a reconnect can take it.
|
|
193
|
+
* Claude reconnects a dropped remote server with exponential backoff; stdio servers are
|
|
194
|
+
* local processes and are not reconnected. Shutdown closes clients while they are still
|
|
195
|
+
* in the map, so the flag guards against scheduling a reconnect for a deliberate
|
|
196
|
+
* teardown. Called by the transport's onclose and by a call that found the connection
|
|
197
|
+
* dead (isConnectionLost), so several calls failing together schedule one reconnect. */
|
|
198
|
+
function dropClient(name: string, client: Client, config: ServerConfig): void {
|
|
199
|
+
if (clients.get(name) !== client) return
|
|
200
|
+
clients.delete(name)
|
|
201
|
+
status.set(name, { state: 'disconnected', tools: 0 })
|
|
202
|
+
if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
/** Claude's mid-session reconnect for a dropped remote server: five attempts with
|
|
193
206
|
* a delay doubling from one second. connectServers redoes the full bring-up
|
|
194
207
|
* (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
|
|
@@ -205,17 +218,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
205
218
|
}
|
|
206
219
|
}
|
|
207
220
|
|
|
221
|
+
// One auth recovery per server at a time, shared by every call that met the same 401.
|
|
222
|
+
const authReconnects = new Map<string, Promise<void>>()
|
|
223
|
+
|
|
208
224
|
/** Claude's 401/403 tool-call recovery: drop the client and reconnect once, so the
|
|
209
225
|
* headersHelper re-runs (fresh credential) or the OAuth tokens refresh, then the
|
|
210
226
|
* caller retries the call once. The map delete precedes the close so the onclose
|
|
211
|
-
* guard does not also schedule a backoff reconnect.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
* guard does not also schedule a backoff reconnect. Parallel calls that all met the
|
|
228
|
+
* expired credential share the one reconnect: `failed` is the client a call was made
|
|
229
|
+
* on, and a map holding another one means a sibling already replaced it, so closing
|
|
230
|
+
* "the client in the map" would tear down the fresh connection under that sibling. */
|
|
231
|
+
function reconnectForAuth(name: string, config: ServerConfig, failed: Client | undefined): Promise<void> {
|
|
232
|
+
const inFlight = authReconnects.get(name)
|
|
233
|
+
if (inFlight !== undefined) return inFlight
|
|
234
|
+
if (clients.get(name) !== failed) return Promise.resolve()
|
|
235
|
+
const recovery = (async () => {
|
|
215
236
|
clients.delete(name)
|
|
216
|
-
await withTimeout(
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
if (failed) await withTimeout(failed.close(), 3000, 'close').catch(() => {})
|
|
238
|
+
await connectServers({ [name]: config }, sessionAuthUi, true)
|
|
239
|
+
})().finally(() => authReconnects.delete(name))
|
|
240
|
+
authReconnects.set(name, recovery)
|
|
241
|
+
return recovery
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
/** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
|
|
@@ -223,16 +246,26 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
223
246
|
async function callToolWithAuthRetry(name: string, config: ServerConfig, args: { name: string; arguments: Record<string, unknown> }, label: string): Promise<Awaited<ReturnType<Client['callTool']>>> {
|
|
224
247
|
const tuning = serverCallTuning(config)
|
|
225
248
|
const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
|
|
249
|
+
let used: Client | undefined
|
|
226
250
|
const callOnce = async () => {
|
|
227
251
|
const current = clients.get(name)
|
|
228
252
|
if (!current) throw new Error(`MCP server "${name}" is not connected`)
|
|
229
|
-
|
|
253
|
+
used = current
|
|
254
|
+
try {
|
|
255
|
+
return await withTimeout(current.callTool(args, undefined, callRequestOptions(wall, tuning)), wall, label)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (isConnectionLost(error)) {
|
|
258
|
+
dropClient(name, current, config)
|
|
259
|
+
void withTimeout(current.close(), 3000, 'close').catch(() => {})
|
|
260
|
+
}
|
|
261
|
+
throw error
|
|
262
|
+
}
|
|
230
263
|
}
|
|
231
264
|
try {
|
|
232
265
|
return await callOnce()
|
|
233
266
|
} catch (error) {
|
|
234
267
|
if (!isUnauthorized(error)) throw error
|
|
235
|
-
await reconnectForAuth(name, config)
|
|
268
|
+
await reconnectForAuth(name, config, used)
|
|
236
269
|
return await callOnce()
|
|
237
270
|
}
|
|
238
271
|
}
|
|
@@ -463,16 +496,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
463
496
|
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
464
497
|
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
465
498
|
// status and free the name so a later session start can reconnect it.
|
|
466
|
-
client.onclose = () =>
|
|
467
|
-
if (clients.get(name) !== client) return
|
|
468
|
-
clients.delete(name)
|
|
469
|
-
status.set(name, { state: 'disconnected', tools: 0 })
|
|
470
|
-
// Claude reconnects a dropped remote server with exponential backoff;
|
|
471
|
-
// stdio servers are local processes and are not reconnected. Shutdown
|
|
472
|
-
// closes clients while they are still in the map, so the flag guards
|
|
473
|
-
// against scheduling a reconnect for a deliberate teardown.
|
|
474
|
-
if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
|
|
475
|
-
}
|
|
499
|
+
client.onclose = () => dropClient(name, client, config)
|
|
476
500
|
} catch (error) {
|
|
477
501
|
status.set(name, { state: `failed: ${errorMessage(error)}`, tools: 0 })
|
|
478
502
|
// Connected but failed after (tool listing hung or errored): left in the
|
|
@@ -539,7 +563,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
539
563
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
540
564
|
// the user's own entry wins a name clash with a plugin's. A server toggled off
|
|
541
565
|
// in ~/.claude.json's per-project disabledMcpServers list never connects.
|
|
542
|
-
const pluginServers = loadPluginServers(installedPlugins(os.homedir()),
|
|
566
|
+
const pluginServers = loadPluginServers(installedPlugins(os.homedir()), checkoutRoot(ctx.cwd))
|
|
543
567
|
const disabled = disabledServerNames(os.homedir(), ctx.cwd)
|
|
544
568
|
const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }).filter(([name]) => !disabled.has(name)))
|
|
545
569
|
const scoped = applyServerPolicy(merged, policy)
|
|
@@ -622,7 +646,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
622
646
|
projectConnected = false
|
|
623
647
|
// Claude answers roots/list with the session's launch directory and exports the
|
|
624
648
|
// project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
|
|
625
|
-
sessionDirs = { projectDir:
|
|
649
|
+
sessionDirs = { projectDir: checkoutRoot(ctx.cwd), launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
|
|
626
650
|
const authUi = authUiFor(ctx)
|
|
627
651
|
sessionAuthUi = authUi
|
|
628
652
|
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
@@ -4,11 +4,25 @@
|
|
|
4
4
|
* blocks mapped into pi's output budget.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
|
|
7
9
|
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
|
|
8
10
|
import { capForContext } from '../internal/output-guard.js'
|
|
9
11
|
|
|
12
|
+
/** Providers take a tool name only as `^[a-zA-Z0-9_-]{1,64}$` (64 is OpenAI's cap, the
|
|
13
|
+
* tighter of the two) and reject the whole request otherwise, so one bad name failed every
|
|
14
|
+
* turn of the session. */
|
|
15
|
+
const MAX_TOOL_NAME = 64
|
|
16
|
+
|
|
17
|
+
/** The pi tool name for a server's tool: `server_tool` with every character a provider
|
|
18
|
+
* rejects (the MCP spec allows dots; a server name is whatever the user typed) as an
|
|
19
|
+
* underscore. A name over the limit is cut and ends in a hash of the whole, so two long
|
|
20
|
+
* tools of one server stay apart and a tool keeps its name from one session to the next. */
|
|
10
21
|
export function formatToolName(server: string, tool: string): string {
|
|
11
|
-
|
|
22
|
+
const name = `${server}_${tool}`.replace(/\W/g, '_')
|
|
23
|
+
if (name.length <= MAX_TOOL_NAME) return name
|
|
24
|
+
const digest = createHash('sha256').update(name).digest('hex').slice(0, 8)
|
|
25
|
+
return `${name.slice(0, MAX_TOOL_NAME - digest.length - 1)}_${digest}`
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
/** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands: any
|
|
@@ -339,6 +339,21 @@ export function isTransientConnectError(error: unknown): boolean {
|
|
|
339
339
|
return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** Whether a failed call means the server is gone, not that the call was wrong: a refused,
|
|
343
|
+
* reset or unreachable connection, the 404 a restarted stateful server answers for a
|
|
344
|
+
* session it forgot (the spec has the client start a new one), or the SDK's own
|
|
345
|
+
* closed-connection errors. The SDK fires `Client.onclose` only from `close()`, never when
|
|
346
|
+
* an HTTP or SSE server disappears, so a call is the one place a dead server shows.
|
|
347
|
+
* Narrower than isTransientConnectError on purpose: a tool timing out, or a tool's own
|
|
348
|
+
* 5xx, says nothing about the connection and must not drop a healthy server. The closed
|
|
349
|
+
* connection is matched by the SDK's message, not its -32000 code, which servers also use
|
|
350
|
+
* for errors of their own. */
|
|
351
|
+
export function isConnectionLost(error: unknown): boolean {
|
|
352
|
+
const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
|
|
353
|
+
if (code === 404 || errorCodes(error).some((one) => TRANSIENT_CODES.has(one))) return true
|
|
354
|
+
return /\(HTTP 404\)|^Not connected$|^MCP error -32000: Connection closed$/.test(error instanceof Error ? error.message : '')
|
|
355
|
+
}
|
|
356
|
+
|
|
342
357
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
343
358
|
|
|
344
359
|
/** Connect with Claude's first-connection retry: an HTTP or SSE server's transient
|
|
@@ -36,7 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
|
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
38
|
import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
39
|
-
import {
|
|
39
|
+
import { checkoutRoot, gitRoot } from './internal/project-root.js'
|
|
40
40
|
import { readSettingsChain } from './internal/settings-chain.js'
|
|
41
41
|
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
42
42
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
@@ -283,7 +283,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
283
283
|
// directory. added_dirs comes from the --add-dir flag pi-code registers.
|
|
284
284
|
workspace: {
|
|
285
285
|
current_dir: ctx.cwd,
|
|
286
|
-
project_dir:
|
|
286
|
+
project_dir: checkoutRoot(ctx.cwd),
|
|
287
287
|
git_worktree: isGitWorktree(ctx.cwd),
|
|
288
288
|
added_dirs: addedDirs(),
|
|
289
289
|
},
|
|
@@ -14,7 +14,7 @@ import { claudeConfigDir } from '../internal/config-dir.js'
|
|
|
14
14
|
import { parseClaudeFrontmatter } from '../internal/frontmatter.js'
|
|
15
15
|
import { findModel } from '../internal/model-lookup.js'
|
|
16
16
|
import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
|
|
17
|
-
import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
|
|
17
|
+
import { ancestorDirs, findNearestDir, sameLocation } from '../internal/project-root.js'
|
|
18
18
|
import { errorMessage } from '../internal/values.js'
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -371,7 +371,12 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
371
371
|
// Claude scans every .claude/agents between cwd and the repository root, the
|
|
372
372
|
// definition closest to cwd winning a name clash; root-first load order makes
|
|
373
373
|
// the nearer directory's entry overwrite in the map below.
|
|
374
|
-
|
|
374
|
+
// The user's own directory is not a project one: at $HOME, or under a dotfiles repo
|
|
375
|
+
// rooted there, the walk reaches ~/.claude/agents and would gate the user's agents as
|
|
376
|
+
// repo-controlled.
|
|
377
|
+
const projectClaudeDirs = ancestorDirs(cwd, path.join('.claude', 'agents'))
|
|
378
|
+
.filter((dir) => !sameLocation(dir, claudeUserDir))
|
|
379
|
+
.reverse()
|
|
375
380
|
|
|
376
381
|
// Plugins load after builtins and before the user's own dirs, so a user agent
|
|
377
382
|
// wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.74",
|
|
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",
|