pi-code 1.0.8 → 1.0.10
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 +33 -29
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +284 -51
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +108 -18
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +60 -3
- package/extensions/mcp.ts +186 -41
- package/extensions/memory.ts +118 -4
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +3 -2
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +19 -6
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
package/extensions/mcp.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
|
|
|
48
48
|
import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
49
49
|
import { Type } from 'typebox'
|
|
50
50
|
import { splitArgs } from './internal/command-file.js'
|
|
51
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
51
52
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
52
53
|
import { setMcpToolCaller } from './internal/mcp-call.js'
|
|
53
54
|
import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
|
|
@@ -57,7 +58,14 @@ import { isProjectApproved, isProjectApprovedSilently } from './internal/project
|
|
|
57
58
|
import { findNearestFile } from './internal/project-root.js'
|
|
58
59
|
|
|
59
60
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
60
|
-
|
|
61
|
+
// Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
|
|
62
|
+
// is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
|
|
63
|
+
// so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
|
|
64
|
+
const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
|
|
65
|
+
// The idle timeout: the longest a call may go with no response or progress before it is
|
|
66
|
+
// abandoned. Claude uses a separate idle guard (minutes) rather than the hours-long
|
|
67
|
+
// wall-clock budget; the SDK resets this window on every progress notification.
|
|
68
|
+
const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
|
|
61
69
|
|
|
62
70
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
63
71
|
function envTimeout(name: string, fallback: number): number {
|
|
@@ -70,6 +78,33 @@ function envTimeout(name: string, fallback: number): number {
|
|
|
70
78
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
71
79
|
const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
72
80
|
const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
81
|
+
|
|
82
|
+
/** The idle timeout in ms: the longest a call may go with no response or progress before
|
|
83
|
+
* it is abandoned, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
|
|
84
|
+
* (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
|
|
85
|
+
* "disabled" rather than falling back to the default. */
|
|
86
|
+
function idleTimeoutMs(): number {
|
|
87
|
+
const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
|
|
88
|
+
if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
|
|
89
|
+
const value = Number.parseInt(raw, 10)
|
|
90
|
+
if (value === 0) return 0
|
|
91
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
|
|
95
|
+
* under it, an idle timeout the SDK resets on every progress notification. When the idle
|
|
96
|
+
* window is enabled and tighter than the wall budget, `timeout` is that per-quiet-period
|
|
97
|
+
* deadline (resetTimeoutOnProgress), maxTotalTimeout caps the wall clock, and an onprogress
|
|
98
|
+
* handler is required: it makes the server address progress to this request and lets the
|
|
99
|
+
* SDK reset the timer on it. When the idle timeout is disabled, or already looser than the
|
|
100
|
+
* wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
|
|
101
|
+
* backstop and must be raced against `wall`, never the idle window, so a legitimately
|
|
102
|
+
* progressing call is not cut off. */
|
|
103
|
+
function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
|
|
104
|
+
const idle = idleTimeoutMs()
|
|
105
|
+
if (idle === 0 || idle >= wall) return { timeout: wall }
|
|
106
|
+
return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
|
|
107
|
+
}
|
|
73
108
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
74
109
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
75
110
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -125,9 +160,19 @@ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.e
|
|
|
125
160
|
})
|
|
126
161
|
}
|
|
127
162
|
|
|
128
|
-
/**
|
|
163
|
+
/** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
|
|
164
|
+
* When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
|
|
165
|
+
* default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
|
|
166
|
+
* blank value is treated as unset, matching claudeConfigDir. */
|
|
167
|
+
function claudeJsonPath(home: string): string {
|
|
168
|
+
const override = process.env.CLAUDE_CONFIG_DIR
|
|
169
|
+
return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
|
|
173
|
+
* tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
|
|
129
174
|
export function userConfigPaths(home: string): string[] {
|
|
130
|
-
return [
|
|
175
|
+
return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
@@ -163,7 +208,7 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
210
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
166
|
-
const userSettings = read(path.join(home, '
|
|
211
|
+
const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
|
|
167
212
|
const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
|
|
168
213
|
const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
|
|
169
214
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
@@ -208,7 +253,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
208
253
|
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
209
254
|
const servers = loadConfigFrom(userConfigPaths(home))
|
|
210
255
|
try {
|
|
211
|
-
const claudeJson = JSON.parse(fs.readFileSync(
|
|
256
|
+
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
212
257
|
Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
|
|
213
258
|
} catch {
|
|
214
259
|
// missing or invalid ~/.claude.json: the top-level user servers already loaded
|
|
@@ -321,6 +366,47 @@ export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?
|
|
|
321
366
|
}
|
|
322
367
|
}
|
|
323
368
|
|
|
369
|
+
/** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
|
|
370
|
+
* through the same test seam so a test can write both into one temp dir. */
|
|
371
|
+
export function managedMcpPath(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): string {
|
|
372
|
+
return path.join(path.dirname(managedFile), 'managed-mcp.json')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Claude's managed-mcp.json: when it exists beside managed-settings.json it takes
|
|
376
|
+
* exclusive control of MCP. Only its `mcpServers` load; user, project, and plugin servers
|
|
377
|
+
* are all suppressed (and the project-approval flow with them), and an empty map disables
|
|
378
|
+
* MCP entirely. Returns the managed server map (possibly empty) when the file exists and
|
|
379
|
+
* parses, or null only when the file is absent, in which case MCP loads from the usual
|
|
380
|
+
* scopes exactly as before. A file that parses but carries no `mcpServers` object is an
|
|
381
|
+
* empty managed set, so a deployed-but-bodyless policy locks down rather than silently
|
|
382
|
+
* reopening the other scopes. A file that is PRESENT but not valid JSON fails closed to
|
|
383
|
+
* the same empty set (deny-all) rather than reopening those scopes: the lockdown intent
|
|
384
|
+
* means a corrupt or truncated policy file must not become an allow-all. The allow/deny
|
|
385
|
+
* lists still filter the returned set. */
|
|
386
|
+
export function loadManagedMcpServers(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, ServerConfig> | null {
|
|
387
|
+
const file = managedMcpPath(managedFile)
|
|
388
|
+
let raw: string
|
|
389
|
+
try {
|
|
390
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
391
|
+
} catch {
|
|
392
|
+
// Absent (or unreadable) managed-mcp.json: no managed MCP control, load normally.
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
let parsed: unknown
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(raw)
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
|
|
400
|
+
// rather than reopening the user/project/plugin scopes.
|
|
401
|
+
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
|
|
402
|
+
return {}
|
|
403
|
+
}
|
|
404
|
+
if (parsed === null || typeof parsed !== 'object') return {}
|
|
405
|
+
const servers = (parsed as { mcpServers?: unknown }).mcpServers
|
|
406
|
+
if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return {}
|
|
407
|
+
return servers as Record<string, ServerConfig>
|
|
408
|
+
}
|
|
409
|
+
|
|
324
410
|
/** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
|
|
325
411
|
* a set (even empty) is exclusive, so only its members survive; a deny list removes
|
|
326
412
|
* servers on top, deny winning over allow. */
|
|
@@ -649,6 +735,23 @@ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport //
|
|
|
649
735
|
|
|
650
736
|
type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
|
|
651
737
|
|
|
738
|
+
/** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
|
|
739
|
+
* at once (a user-scope and a consented project-scope server both 401ing, connecting in
|
|
740
|
+
* parallel) would stack dialogs and browser tabs. This chains them so a second
|
|
741
|
+
* interactive login waits for the first to settle; the tail is reset to a resolved
|
|
742
|
+
* promise regardless of outcome, so a failed login never poisons the queue. Silent
|
|
743
|
+
* (stored-token) connects do not pass through here and stay fully parallel. */
|
|
744
|
+
let oauthQueue: Promise<unknown> = Promise.resolve()
|
|
745
|
+
|
|
746
|
+
function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
|
|
747
|
+
const result = oauthQueue.then(run, run)
|
|
748
|
+
oauthQueue = result.then(
|
|
749
|
+
() => {},
|
|
750
|
+
() => {},
|
|
751
|
+
)
|
|
752
|
+
return result
|
|
753
|
+
}
|
|
754
|
+
|
|
652
755
|
/**
|
|
653
756
|
* Connect an http-family server, running Claude's OAuth login when the server
|
|
654
757
|
* demands one. Stored tokens ride the first attempt so the SDK refreshes
|
|
@@ -671,7 +774,7 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
|
|
|
671
774
|
} catch (error) {
|
|
672
775
|
if (bearerToken || !isUnauthorized(error)) throw error
|
|
673
776
|
if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
|
|
674
|
-
return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
|
|
777
|
+
return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
|
|
675
778
|
}
|
|
676
779
|
}
|
|
677
780
|
|
|
@@ -782,7 +885,7 @@ function resourceTemplateEntry(server: string, template: { uriTemplate: string;
|
|
|
782
885
|
async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
|
|
783
886
|
let cursor: string | undefined
|
|
784
887
|
do {
|
|
785
|
-
const page = await withTimeout(client.listResources({ cursor },
|
|
888
|
+
const page = await withTimeout(client.listResources({ cursor }, callRequestOptions(budget)), budget, `list resources ${name}`)
|
|
786
889
|
for (const resource of page.resources) entries.push(resourceEntry(name, resource))
|
|
787
890
|
cursor = page.nextCursor
|
|
788
891
|
} while (cursor)
|
|
@@ -792,7 +895,7 @@ async function collectResources(entries: Array<Record<string, unknown>>, name: s
|
|
|
792
895
|
async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
|
|
793
896
|
let cursor: string | undefined
|
|
794
897
|
do {
|
|
795
|
-
const page = await withTimeout(client.listResourceTemplates({ cursor },
|
|
898
|
+
const page = await withTimeout(client.listResourceTemplates({ cursor }, callRequestOptions(budget)), budget, `list resource templates ${name}`)
|
|
796
899
|
for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
|
|
797
900
|
cursor = page.nextCursor
|
|
798
901
|
} while (cursor)
|
|
@@ -828,7 +931,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
828
931
|
setMcpToolCaller(async (server, tool, input) => {
|
|
829
932
|
const client = clients.get(server)
|
|
830
933
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
831
|
-
const result = await client.callTool({ name: tool, arguments: input }, undefined,
|
|
934
|
+
const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
|
|
832
935
|
const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
833
936
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
834
937
|
.map((part) => part.text)
|
|
@@ -866,12 +969,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
866
969
|
// and this closure would otherwise keep calling the old, closed client.
|
|
867
970
|
const current = clients.get(name)
|
|
868
971
|
if (!current) throw new Error(`MCP server "${name}" is not connected`)
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
972
|
+
// The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
|
|
973
|
+
// wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
|
|
974
|
+
// the SDK enforces (resetting on progress). Pass the options to the SDK too: its
|
|
975
|
+
// own default request timeout is 60s and would otherwise reject first. The outer
|
|
976
|
+
// race uses the wall budget, never the idle window, so a progressing call is not
|
|
977
|
+
// cut off at the idle timeout.
|
|
872
978
|
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
|
|
873
|
-
const
|
|
874
|
-
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined,
|
|
979
|
+
const wall = declared ?? callTimeoutMs()
|
|
980
|
+
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
|
|
875
981
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
876
982
|
const details: { error?: string } = {}
|
|
877
983
|
if (result.isError) {
|
|
@@ -923,8 +1029,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
923
1029
|
const promptArgs = mapPromptArguments(prompt.arguments, args)
|
|
924
1030
|
const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
|
|
925
1031
|
if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
|
|
926
|
-
const
|
|
927
|
-
const result = await withTimeout(current.getPrompt(params,
|
|
1032
|
+
const wall = callTimeoutMs()
|
|
1033
|
+
const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
|
|
928
1034
|
// The prompt drives a turn exactly the way a custom slash command does
|
|
929
1035
|
// (see commands.ts), carrying its image blocks through. A prompt that
|
|
930
1036
|
// yields no content is reported rather than sent as an empty turn.
|
|
@@ -1010,8 +1116,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1010
1116
|
const { server, uri } = params as { server: string; uri: string }
|
|
1011
1117
|
const client = clients.get(server)
|
|
1012
1118
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
1013
|
-
const
|
|
1014
|
-
const result = await withTimeout(client.readResource({ uri },
|
|
1119
|
+
const wall = callTimeoutMs()
|
|
1120
|
+
const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
|
|
1015
1121
|
const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
|
|
1016
1122
|
if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
|
|
1017
1123
|
if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
|
|
@@ -1113,17 +1219,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1113
1219
|
)
|
|
1114
1220
|
}
|
|
1115
1221
|
|
|
1116
|
-
/** Connect the project
|
|
1117
|
-
* is settled, so a refused confirm can be retried on a
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
const approved = isProjectApprovedSilently(ctx)
|
|
1122
|
-
const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
|
|
1123
|
-
const { allowed, denied } = mcpAllowDeny()
|
|
1124
|
-
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
|
|
1125
|
-
const authUi = authUiFor(ctx)
|
|
1126
|
-
if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
|
|
1222
|
+
/** Connect the approval-gated project servers, behind the whole-project confirm.
|
|
1223
|
+
* Returns whether the scope is settled, so a refused confirm can be retried on a
|
|
1224
|
+
* later session start. The consented half of the project scope connects earlier,
|
|
1225
|
+
* concurrently with the user scope, from session_start itself. */
|
|
1226
|
+
async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
|
|
1127
1227
|
if (Object.keys(gated).length === 0) return true
|
|
1128
1228
|
if (!(await isProjectApproved(ctx))) return false
|
|
1129
1229
|
await connectServers(gated, authUi)
|
|
@@ -1132,15 +1232,32 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1132
1232
|
|
|
1133
1233
|
let projectConnected = false
|
|
1134
1234
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1235
|
+
/** managed-mcp.json exclusive mode: a policy deployed mid-process must not leave
|
|
1236
|
+
* already-connected user/project servers running alongside the managed set. Evict every
|
|
1237
|
+
* connected client not in the managed set (delete it from the map first so the onclose
|
|
1238
|
+
* handler's guard sees it gone and does not overwrite the status, then close it
|
|
1239
|
+
* best-effort and mark it disabled), then connect only the managed servers. */
|
|
1240
|
+
async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
|
|
1241
|
+
const managedServers = applyServerPolicy(managed, allowed, denied)
|
|
1242
|
+
const managedNames = new Set(Object.keys(managedServers))
|
|
1243
|
+
for (const [name, client] of Array.from(clients.entries())) {
|
|
1244
|
+
if (managedNames.has(name)) continue
|
|
1245
|
+
clients.delete(name)
|
|
1246
|
+
await client.close().catch(() => {})
|
|
1247
|
+
status.set(name, { state: 'disabled by managed policy', tools: 0 })
|
|
1248
|
+
}
|
|
1249
|
+
await connectServers(managedServers, authUi)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** The normal user + plugin + project scopes, when no managed-mcp.json is present.
|
|
1253
|
+
* Connecting spawns processes and opens sockets, so it belongs here rather than in the
|
|
1254
|
+
* factory: pi runs the factory for invocations that never start a session. Names still
|
|
1255
|
+
* connected are filtered out, so a later session start only retries servers that failed
|
|
1256
|
+
* or whose transport dropped, without duplicate-name warnings. */
|
|
1257
|
+
async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
|
|
1140
1258
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
1141
1259
|
// the user's own entry wins a name clash with a plugin's.
|
|
1142
1260
|
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
1143
|
-
const { allowed, denied } = mcpAllowDeny()
|
|
1144
1261
|
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
|
|
1145
1262
|
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
1146
1263
|
// server only outranks the user's own when it will actually connect (the user already
|
|
@@ -1148,16 +1265,44 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1148
1265
|
// entry cannot shadow a trusted user server by reusing its name. A gated project
|
|
1149
1266
|
// server still awaiting the approval prompt does not preempt the user server: that is
|
|
1150
1267
|
// a deliberate narrowing of Claude's rule to keep the safe default.
|
|
1268
|
+
// The stored project decision, read without prompting: consent recorded inside
|
|
1269
|
+
// the project only counts once the project itself has been approved.
|
|
1151
1270
|
const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
1152
|
-
const
|
|
1271
|
+
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
|
|
1272
|
+
const projectWinners = new Set(Object.keys(consented))
|
|
1153
1273
|
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
1154
|
-
|
|
1274
|
+
// The consented project servers carry no ordering dependency on the user scope:
|
|
1275
|
+
// projectWinners already excludes their names from userServers, so the two batches
|
|
1276
|
+
// are disjoint and connect concurrently, and startup pays the slower scope rather
|
|
1277
|
+
// than the sum of both. Reconnect attempts after a refused confirm are safe:
|
|
1278
|
+
// connectServers skips names that already connected.
|
|
1279
|
+
const connects: Promise<void>[] = []
|
|
1280
|
+
if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
|
|
1281
|
+
if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
|
|
1282
|
+
await Promise.all(connects)
|
|
1155
1283
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
1156
1284
|
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
1157
|
-
// connect, servers the user consented to individually
|
|
1158
|
-
// whole-project confirm, and the rest stay behind it
|
|
1159
|
-
//
|
|
1160
|
-
if (!projectConnected) projectConnected = await
|
|
1285
|
+
// connect, servers the user consented to individually connected above without the
|
|
1286
|
+
// whole-project confirm, and the rest stay behind it, sequentially after both
|
|
1287
|
+
// scopes so the confirm dialog never races a connect.
|
|
1288
|
+
if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1292
|
+
const authUi = authUiFor(ctx)
|
|
1293
|
+
// The managed allow/deny lists filter every scope, including a managed-mcp.json set.
|
|
1294
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
1295
|
+
// managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
|
|
1296
|
+
// only its servers load, and the user, project, and plugin scopes plus the whole
|
|
1297
|
+
// project-approval flow below are skipped. An empty map disables MCP entirely. An absent
|
|
1298
|
+
// file leaves the normal scopes untouched; a present but corrupt file fails closed to an
|
|
1299
|
+
// empty set (see loadManagedMcpServers).
|
|
1300
|
+
const managed = loadManagedMcpServers()
|
|
1301
|
+
if (managed !== null) {
|
|
1302
|
+
await connectManagedExclusive(managed, allowed, denied, authUi)
|
|
1303
|
+
} else {
|
|
1304
|
+
await connectNormalScopes(ctx, allowed, denied, authUi)
|
|
1305
|
+
}
|
|
1161
1306
|
|
|
1162
1307
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
1163
1308
|
|
package/extensions/memory.ts
CHANGED
|
@@ -14,11 +14,12 @@ import * as path from 'node:path'
|
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
15
|
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
17
18
|
import { capForContext } from './internal/output-guard.js'
|
|
18
19
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
19
20
|
import { findNearestFile, repoRoot } from './internal/project-root.js'
|
|
20
21
|
|
|
21
|
-
const INDEX_FILE = 'MEMORY.md'
|
|
22
|
+
export const INDEX_FILE = 'MEMORY.md'
|
|
22
23
|
|
|
23
24
|
/** Claude loads the first 200 lines or 25KB of the memory index at startup. */
|
|
24
25
|
export const INDEX_MAX_LINES = 200
|
|
@@ -282,7 +283,7 @@ function writeIndex(indexPath: string, content: string): void {
|
|
|
282
283
|
* approved, since a project's `autoMemoryDirectory` is honored under the same trust
|
|
283
284
|
* rule as hooks in settings files. Later files win. */
|
|
284
285
|
export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
285
|
-
const files = [path.join(home, '
|
|
286
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
286
287
|
if (!approved) return files
|
|
287
288
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
288
289
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -306,10 +307,65 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
|
|
|
306
307
|
return merged
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
/** Write `autoMemoryEnabled` into the user settings file, preserving every other key
|
|
311
|
+
* and creating the file and its config directory when absent. Claude's /memory toggle
|
|
312
|
+
* writes to the user scope (relocated by CLAUDE_CONFIG_DIR); the value takes effect from
|
|
313
|
+
* the next session start, which is where autoMemoryEnabled is read.
|
|
314
|
+
*
|
|
315
|
+
* An absent file starts from an empty object so the toggle still lands. A file that is
|
|
316
|
+
* PRESENT but unparseable is refused, not overwritten: clobbering it would destroy the
|
|
317
|
+
* user's hooks, env and permissions config. The caller surfaces the returned failure. */
|
|
318
|
+
export function setAutoMemoryEnabledSetting(home: string, value: boolean): { ok: true } | { ok: false; error: string } {
|
|
319
|
+
const dir = claudeConfigDir(home)
|
|
320
|
+
const file = path.join(dir, 'settings.json')
|
|
321
|
+
let current: Record<string, unknown> = {}
|
|
322
|
+
let raw: string | undefined
|
|
323
|
+
try {
|
|
324
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// Only a missing file means start fresh; any other read failure propagates.
|
|
327
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
328
|
+
}
|
|
329
|
+
if (raw !== undefined) {
|
|
330
|
+
try {
|
|
331
|
+
const parsed = JSON.parse(raw)
|
|
332
|
+
if (parsed !== null && typeof parsed === 'object') current = parsed as Record<string, unknown>
|
|
333
|
+
} catch {
|
|
334
|
+
// Present but unparseable: refuse rather than overwrite the user's config.
|
|
335
|
+
return { ok: false, error: 'settings.json is not valid JSON; not modified' }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
current.autoMemoryEnabled = value
|
|
339
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
340
|
+
fs.writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`)
|
|
341
|
+
return { ok: true }
|
|
342
|
+
}
|
|
343
|
+
|
|
309
344
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
310
345
|
let dir = memoryDir(process.cwd())
|
|
311
346
|
let enabled = true
|
|
312
347
|
|
|
348
|
+
// The index is injected every turn but changes only through the tool or an external
|
|
349
|
+
// edit, so a turn costs one stat instead of a full read. The stat token (mtime plus
|
|
350
|
+
// size) catches external edits; save and delete drop the cache outright, since a
|
|
351
|
+
// rename landing within one mtime tick at the same size would slip past the token.
|
|
352
|
+
let indexCache: { token: string; index: string } | null = null
|
|
353
|
+
|
|
354
|
+
const indexStatToken = (): string => {
|
|
355
|
+
try {
|
|
356
|
+
const stat = fs.statSync(path.join(dir, INDEX_FILE))
|
|
357
|
+
return `${stat.mtimeMs}:${stat.size}`
|
|
358
|
+
} catch {
|
|
359
|
+
return 'missing'
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const readIndexCached = (): string => {
|
|
364
|
+
const token = indexStatToken()
|
|
365
|
+
if (indexCache?.token !== token) indexCache = { token, index: readIndexQuietly(dir) }
|
|
366
|
+
return indexCache.index
|
|
367
|
+
}
|
|
368
|
+
|
|
313
369
|
// These extensions also load inside spawned subagent processes, which carry the
|
|
314
370
|
// PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
|
|
315
371
|
// into subagents (they get their own store through the agent `memory:` field), so
|
|
@@ -325,6 +381,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
325
381
|
enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
|
|
326
382
|
const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
|
|
327
383
|
dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
|
|
384
|
+
indexCache = null
|
|
328
385
|
if (!enabled) return
|
|
329
386
|
const count = readIndexQuietly(dir)
|
|
330
387
|
.split('\n')
|
|
@@ -334,7 +391,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
334
391
|
|
|
335
392
|
pi.on('before_agent_start', async (event) => {
|
|
336
393
|
if (inSubagent() || !enabled) return
|
|
337
|
-
const index =
|
|
394
|
+
const index = readIndexCached()
|
|
338
395
|
if (!index.trim()) return
|
|
339
396
|
return {
|
|
340
397
|
systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
|
|
@@ -362,6 +419,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
362
419
|
return await saveMemory(dir, indexPath, name, params.description, params.content)
|
|
363
420
|
} catch (error) {
|
|
364
421
|
return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
|
|
422
|
+
} finally {
|
|
423
|
+
indexCache = null
|
|
365
424
|
}
|
|
366
425
|
}
|
|
367
426
|
|
|
@@ -372,11 +431,66 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
372
431
|
|
|
373
432
|
if (params.action === 'delete') {
|
|
374
433
|
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
375
|
-
|
|
434
|
+
// In a finally like the save path: a delete that throws mid-write must still
|
|
435
|
+
// drop the cache, or the next turn injects a stale index.
|
|
436
|
+
try {
|
|
437
|
+
return await deleteMemory(dir, indexPath, name)
|
|
438
|
+
} finally {
|
|
439
|
+
indexCache = null
|
|
440
|
+
}
|
|
376
441
|
}
|
|
377
442
|
|
|
378
443
|
const index = readIndexQuietly(dir)
|
|
379
444
|
return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
|
|
380
445
|
},
|
|
381
446
|
})
|
|
447
|
+
|
|
448
|
+
// Claude's /memory lists the memory locations and toggles auto memory. pi has no
|
|
449
|
+
// editor seam, so the paths are printed rather than opened. The listing reads the
|
|
450
|
+
// settings chain live so it reflects a toggle written in the same session.
|
|
451
|
+
pi.registerCommand('memory', {
|
|
452
|
+
description: 'Show memory file locations and toggle auto memory (/memory [on|off])',
|
|
453
|
+
handler: async (args, ctx) => {
|
|
454
|
+
const home = os.homedir()
|
|
455
|
+
const arg = args.trim().toLowerCase()
|
|
456
|
+
|
|
457
|
+
if (arg === 'on' || arg === 'off') {
|
|
458
|
+
const next = arg === 'on'
|
|
459
|
+
let result: { ok: true } | { ok: false; error: string }
|
|
460
|
+
try {
|
|
461
|
+
result = setAutoMemoryEnabledSetting(home, next)
|
|
462
|
+
} catch (error) {
|
|
463
|
+
ctx.ui.notify(`Could not update auto memory: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
ctx.ui.notify(result.error, 'error')
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
ctx.ui.notify(`Auto memory ${next ? 'enabled' : 'disabled'} in ${path.join(claudeConfigDir(home), 'settings.json')} (applies next session).`, 'info')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (arg.length > 0) {
|
|
475
|
+
ctx.ui.notify('Usage: /memory [on|off]', 'error')
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
480
|
+
const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, home, approved))
|
|
481
|
+
const isEnabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
|
|
482
|
+
const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
|
|
483
|
+
const store = resolveMemoryDir(ctx.cwd, override)
|
|
484
|
+
const lines = [
|
|
485
|
+
'Memory',
|
|
486
|
+
` Auto memory: ${isEnabled ? 'on' : 'off'}`,
|
|
487
|
+
` Store: ${store}`,
|
|
488
|
+
` Index: ${path.join(store, INDEX_FILE)}`,
|
|
489
|
+
` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
|
|
490
|
+
` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
|
|
491
|
+
'Toggle with /memory on or /memory off.',
|
|
492
|
+
]
|
|
493
|
+
ctx.ui.notify(lines.join('\n'), 'info')
|
|
494
|
+
},
|
|
495
|
+
})
|
|
382
496
|
}
|
package/extensions/notify.ts
CHANGED
|
@@ -21,6 +21,8 @@ import * as os from 'node:os'
|
|
|
21
21
|
import * as path from 'node:path'
|
|
22
22
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
|
|
24
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
25
|
+
|
|
24
26
|
/** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
|
|
25
27
|
export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
|
|
26
28
|
|
|
@@ -55,7 +57,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
|
|
|
55
57
|
* or change your notifications. */
|
|
56
58
|
function readPreferredNotifChannel(home: string): unknown {
|
|
57
59
|
try {
|
|
58
|
-
const settings = JSON.parse(fs.readFileSync(path.join(home, '
|
|
60
|
+
const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
59
61
|
return settings?.preferredNotifChannel
|
|
60
62
|
} catch {
|
|
61
63
|
return undefined
|
|
@@ -24,6 +24,7 @@ import * as os from 'node:os'
|
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
26
|
|
|
27
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
27
28
|
import { installedPlugins } from './internal/plugins.js'
|
|
28
29
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
29
30
|
import { findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
@@ -82,7 +83,7 @@ function isDirectory(target: string): boolean {
|
|
|
82
83
|
* verbatim into the system prompt.
|
|
83
84
|
*/
|
|
84
85
|
export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
85
|
-
const dirs = [path.join(home, '
|
|
86
|
+
const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
|
|
86
87
|
if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
|
|
87
88
|
return dirs.filter((dir) => isDirectory(dir))
|
|
88
89
|
}
|
|
@@ -129,7 +130,7 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
|
|
|
129
130
|
/** Settings files that carry `outputStyle`. Project settings apply only when trusted,
|
|
130
131
|
* each the nearest of its name at or above cwd, as the hooks settings chain reads. */
|
|
131
132
|
export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
132
|
-
const files = [path.join(home, '
|
|
133
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
133
134
|
if (!trusted) return files
|
|
134
135
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
135
136
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
package/extensions/skills.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as os from 'node:os'
|
|
|
15
15
|
import * as path from 'node:path'
|
|
16
16
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
17
17
|
|
|
18
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
18
19
|
import { installedPlugins } from './internal/plugins.js'
|
|
19
20
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
20
21
|
import { findNearestDir } from './internal/project-root.js'
|
|
@@ -33,7 +34,7 @@ function isDirectory(target: string): boolean {
|
|
|
33
34
|
* name and description to the model, so an untrusted repository would otherwise get
|
|
34
35
|
* text into the prompt without the user ever agreeing to load its config. */
|
|
35
36
|
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
36
|
-
const candidates = [path.join(home, '
|
|
37
|
+
const candidates = [path.join(claudeConfigDir(home), 'skills')]
|
|
37
38
|
// Enabled plugins contribute their skills directories. pi's loader names a
|
|
38
39
|
// skill by its directory, so a plugin skill registers without Claude's
|
|
39
40
|
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|