pi-code 1.0.62 → 1.0.63
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/context-imports.ts +43 -21
- package/extensions/git-checkpoint.ts +4 -0
- package/extensions/hooks/index.ts +1 -1
- package/extensions/hooks/runners.ts +10 -1
- package/extensions/internal/mcp-oauth.ts +15 -3
- package/extensions/internal/path-rules.ts +44 -31
- package/extensions/internal/shell-resolve.ts +16 -5
- package/extensions/mcp/index.ts +40 -17
- package/extensions/mcp/transport.ts +10 -0
- package/extensions/question.ts +147 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +6 -1
- package/extensions/subagent/child.ts +17 -2
- package/extensions/subagent/run.ts +32 -7
- package/package.json +1 -1
|
@@ -943,22 +943,25 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
943
943
|
type: 'string',
|
|
944
944
|
})
|
|
945
945
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
946
|
+
/** The session-scope memory session_start loads: the user's own CLAUDE.md, plus
|
|
947
|
+
* CLAUDE.local.md and ./.claude/CLAUDE.md from an approved project. Claude:
|
|
948
|
+
* CLAUDE_CODE_DISABLE_CLAUDE_MDS "prevent[s] loading any CLAUDE.md memory files
|
|
949
|
+
* into context, including user, project, and auto memory files", so a disabled run
|
|
950
|
+
* returns empty state and skips isProjectApproved's trust prompt entirely: there is
|
|
951
|
+
* nothing left for it to gate. Extracted so session_start itself stays a thin
|
|
952
|
+
* dispatcher; before_agent_start's own excluded() check covers pi's native files,
|
|
953
|
+
* which this cannot reach since pi loads those itself. */
|
|
954
|
+
async function loadSessionMemory(ctx: ExtensionContext): Promise<{ userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } }> {
|
|
955
|
+
if (process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1') return { localContexts: [] }
|
|
956
956
|
|
|
957
957
|
// ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
|
|
958
958
|
// project approval is required; a missing file simply leaves it unset.
|
|
959
959
|
const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
|
|
960
960
|
const userContent = readContextFile(userClaudeMd)
|
|
961
|
-
|
|
961
|
+
const memory: { userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } } = {
|
|
962
|
+
userContext: userContent !== undefined ? { path: userClaudeMd, content: userContent } : undefined,
|
|
963
|
+
localContexts: [],
|
|
964
|
+
}
|
|
962
965
|
|
|
963
966
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
964
967
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
@@ -969,16 +972,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
969
972
|
// ride the one approval decision.
|
|
970
973
|
const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
|
|
971
974
|
const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
972
|
-
if ((candidates.length
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
if (dotClaudeMd !== null) {
|
|
978
|
-
const content = readContextFile(dotClaudeMd)
|
|
979
|
-
if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
|
|
980
|
-
}
|
|
975
|
+
if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
|
|
976
|
+
|
|
977
|
+
for (const candidate of candidates) {
|
|
978
|
+
const content = readContextFile(candidate)
|
|
979
|
+
if (content !== undefined) memory.localContexts.push({ path: candidate, content })
|
|
981
980
|
}
|
|
981
|
+
if (dotClaudeMd !== null) {
|
|
982
|
+
const content = readContextFile(dotClaudeMd)
|
|
983
|
+
if (content !== undefined) memory.projectDotClaude = { path: dotClaudeMd, content }
|
|
984
|
+
}
|
|
985
|
+
return memory
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
989
|
+
// pi's ctx.reload() rebuilds extension instances, so this set never survives
|
|
990
|
+
// a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
|
|
991
|
+
// which is fine, since a reload re-loads the instruction files.
|
|
992
|
+
announced.clear()
|
|
993
|
+
envCache = undefined
|
|
994
|
+
importMemo = undefined
|
|
995
|
+
const memory = await loadSessionMemory(ctx)
|
|
996
|
+
localContexts = memory.localContexts
|
|
997
|
+
userContext = memory.userContext
|
|
998
|
+
projectDotClaude = memory.projectDotClaude
|
|
982
999
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
983
1000
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
984
1001
|
gatedFilesApproved = isGatedFileApproved(ctx)
|
|
@@ -1014,7 +1031,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
1014
1031
|
envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
|
|
1015
1032
|
}
|
|
1016
1033
|
const { managed, excludeGlobs, projectRoot } = envCache
|
|
1017
|
-
|
|
1034
|
+
// CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
|
|
1035
|
+
// files ("including... auto memory files"), which session_start's gate cannot reach
|
|
1036
|
+
// since pi loads them itself. Routing through the exclusion path already used for
|
|
1037
|
+
// claudeMdExcludes drops the block, strips the InstructionsLoaded event, and skips
|
|
1038
|
+
// import expansion for it, exactly as an excluded file already does.
|
|
1039
|
+
const excluded = (absPath: string): boolean => process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1' || isExcludedPath(absPath, excludeGlobs, home)
|
|
1018
1040
|
|
|
1019
1041
|
// claudeMdExcludes drops an excluded file's block from the assembled prompt and
|
|
1020
1042
|
// from import expansion; surviving blocks get block-level comments stripped.
|
|
@@ -331,6 +331,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
331
331
|
pi.on('turn_start', async () => {
|
|
332
332
|
if (!runNeedsSnapshot) return
|
|
333
333
|
runNeedsSnapshot = false
|
|
334
|
+
// Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
|
|
335
|
+
// able to restore code changes." No snapshot means turn_end's `if (!snap) return`
|
|
336
|
+
// always fires, so no checkpoint is ever recorded.
|
|
337
|
+
if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
|
|
334
338
|
pending = await snapshot()
|
|
335
339
|
})
|
|
336
340
|
|
|
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
283
283
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
|
|
284
284
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
285
285
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
286
|
-
return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined })
|
|
286
|
+
return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined, sessionId: merged.session_id as string | undefined })
|
|
287
287
|
}
|
|
288
288
|
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
289
289
|
// successful run; a failure, block, or timeout leaves it in place.
|
|
@@ -56,6 +56,10 @@ export interface HookSpawnOptions {
|
|
|
56
56
|
shell?: string
|
|
57
57
|
/** The declaring plugin's paths, exported to the child. */
|
|
58
58
|
plugin?: { root: string; dataDir: string }
|
|
59
|
+
/** Claude: "set automatically to the current session ID in ... hook command
|
|
60
|
+
* subprocesses ... this matches the session_id field in the hook JSON input and
|
|
61
|
+
* is updated on /clear." Absent in a stub context with no session manager. */
|
|
62
|
+
sessionId?: string
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
|
|
@@ -136,7 +140,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
|
|
|
136
140
|
return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin } = {}) =>
|
|
143
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
|
|
140
144
|
new Promise((resolve) => {
|
|
141
145
|
// /bin/sh by absolute path off Windows, so the shell can't be resolved through an
|
|
142
146
|
// attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
|
|
@@ -150,6 +154,11 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, {
|
|
|
150
154
|
// detection cannot see the captured terminal.
|
|
151
155
|
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
|
|
152
156
|
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
157
|
+
// Claude: "Claude Code sets this to its own process ID in the subprocesses it
|
|
158
|
+
// spawns: Bash and PowerShell tool commands and hook commands." Set unconditionally,
|
|
159
|
+
// since every hook child qualifies.
|
|
160
|
+
env.CLAUDE_PID = String(process.pid)
|
|
161
|
+
if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
|
|
153
162
|
// Claude: "All three are exported as environment variables to hook processes and to
|
|
154
163
|
// MCP and LSP server subprocesses", so a plugin script can read them rather than
|
|
155
164
|
// depend on inline substitution. The data directory is "created on first reference",
|
|
@@ -72,6 +72,14 @@ function storeFileFor(serverName: string, endpoint?: string): string {
|
|
|
72
72
|
return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** MCP_OAUTH_CALLBACK_PORT parsed, or undefined when unset or not a plain integer. */
|
|
76
|
+
function envCallbackPort(): number | undefined {
|
|
77
|
+
const raw = process.env.MCP_OAUTH_CALLBACK_PORT
|
|
78
|
+
if (raw === undefined || raw.trim() === '') return undefined
|
|
79
|
+
const parsed = Number(raw)
|
|
80
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
export class FileOAuthProvider implements OAuthClientProvider {
|
|
76
84
|
private readonly storePath: string
|
|
77
85
|
private readonly data: StoredAuth
|
|
@@ -107,15 +115,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
/** The configured callbackPort alone, absent when only a remembered port exists.
|
|
110
|
-
* The caller needs the two apart: a configured port is a hard requirement.
|
|
118
|
+
* The caller needs the two apart: a configured port is a hard requirement. Falls
|
|
119
|
+
* back to MCP_OAUTH_CALLBACK_PORT, Claude's "alternative to --callback-port when
|
|
120
|
+
* adding an MCP server with pre-configured credentials"; pi-code has no `mcp add`
|
|
121
|
+
* command, so the env var applies as a default for any server naming no port of
|
|
122
|
+
* its own rather than only ones added that way. */
|
|
111
123
|
configuredRedirectPort(): number | undefined {
|
|
112
|
-
return this.oauth?.callbackPort
|
|
124
|
+
return this.oauth?.callbackPort ?? envCallbackPort()
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/** The configured callbackPort (Claude: for pre-registered redirect URIs), else
|
|
116
128
|
* the port a prior login registered, so a re-login can bind the same one. */
|
|
117
129
|
savedRedirectPort(): number | undefined {
|
|
118
|
-
return this.oauth?.callbackPort ?? this.data.redirectPort
|
|
130
|
+
return this.oauth?.callbackPort ?? envCallbackPort() ?? this.data.redirectPort
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/** Record the loopback port the callback server actually bound; the redirect
|
|
@@ -96,10 +96,29 @@ function bracketEnd(pattern: string, start: number): number {
|
|
|
96
96
|
|
|
97
97
|
/** A bracket expression body as a regex character class, escaping regex-relevant
|
|
98
98
|
* characters while keeping `-` ranges; a leading `!` (or `^`) negates. */
|
|
99
|
-
function bracketClass(body: string): string {
|
|
99
|
+
function bracketClass(body: string): string | null {
|
|
100
100
|
const negated = body.startsWith('!') || body.startsWith('^')
|
|
101
101
|
const members = (negated ? body.slice(1) : body).replace(/[\\\]^]/g, (ch) => `\\${ch}`)
|
|
102
|
-
|
|
102
|
+
const source = `[${negated ? '^' : ''}${members}]`
|
|
103
|
+
// A range whose endpoints descend, `["- ]` for instance, is not a character class
|
|
104
|
+
// JavaScript will build: RegExp throws "Range out of order in character class". The
|
|
105
|
+
// `-` cannot simply be escaped, since `[a-z]` is the whole point of the syntax, so the
|
|
106
|
+
// class is validated by construction and an unbuildable one is treated exactly as an
|
|
107
|
+
// unterminated `[` already is: the pattern is invalid and matches nothing. Without
|
|
108
|
+
// this the throw escaped compileGlobs, which does not catch, and took the permission
|
|
109
|
+
// check that called it with it.
|
|
110
|
+
return isBuildableClass(source) ? source : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether JavaScript will build this character class. Asking RegExp is the only
|
|
114
|
+
* faithful test: the invalid forms are its rules, not ones worth re-deriving here. */
|
|
115
|
+
function isBuildableClass(source: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
new RegExp(source)
|
|
118
|
+
return true
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
/** A `*` run starting at `i`: a double star followed by a slash spans whole
|
|
@@ -114,39 +133,33 @@ function translateStar(pattern: string, i: number): { source: string; next: numb
|
|
|
114
133
|
return { source: '[^/]*', next: i + 1 }
|
|
115
134
|
}
|
|
116
135
|
|
|
136
|
+
/** The regex source for the construct at `i` and the index after it, or null when the
|
|
137
|
+
* pattern is invalid there and so matches nothing. */
|
|
138
|
+
function translateAt(pattern: string, i: number): { source: string; next: number } | null {
|
|
139
|
+
const ch = pattern[i]
|
|
140
|
+
// Claude: to match a literal bracket, escape it; the escape consumes both chars.
|
|
141
|
+
if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) return { source: escapeRegExp(pattern[i + 1]), next: i + 2 }
|
|
142
|
+
// Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be read
|
|
143
|
+
// as one, or a body that is not a buildable class, makes the pattern invalid.
|
|
144
|
+
if (ch === '[') {
|
|
145
|
+
const end = bracketEnd(pattern, i)
|
|
146
|
+
if (end === -1) return null
|
|
147
|
+
const cls = bracketClass(pattern.slice(i + 1, end))
|
|
148
|
+
return cls === null ? null : { source: cls, next: end + 1 }
|
|
149
|
+
}
|
|
150
|
+
if (ch === '*') return translateStar(pattern, i)
|
|
151
|
+
if (ch === '?') return { source: '[^/]', next: i + 1 }
|
|
152
|
+
return { source: escapeRegExp(ch), next: i + 1 }
|
|
153
|
+
}
|
|
154
|
+
|
|
117
155
|
function translateGlob(pattern: string): string | null {
|
|
118
156
|
let out = ''
|
|
119
157
|
let i = 0
|
|
120
158
|
while (i < pattern.length) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
i += 2
|
|
126
|
-
continue
|
|
127
|
-
}
|
|
128
|
-
// Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be
|
|
129
|
-
// read as one makes the pattern invalid, matching nothing.
|
|
130
|
-
if (ch === '[') {
|
|
131
|
-
const end = bracketEnd(pattern, i)
|
|
132
|
-
if (end === -1) return null
|
|
133
|
-
out += bracketClass(pattern.slice(i + 1, end))
|
|
134
|
-
i = end + 1
|
|
135
|
-
continue
|
|
136
|
-
}
|
|
137
|
-
if (ch === '*') {
|
|
138
|
-
const star = translateStar(pattern, i)
|
|
139
|
-
out += star.source
|
|
140
|
-
i = star.next
|
|
141
|
-
continue
|
|
142
|
-
}
|
|
143
|
-
if (ch === '?') {
|
|
144
|
-
out += '[^/]'
|
|
145
|
-
i += 1
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
out += escapeRegExp(ch)
|
|
149
|
-
i += 1
|
|
159
|
+
const step = translateAt(pattern, i)
|
|
160
|
+
if (step === null) return null
|
|
161
|
+
out += step.source
|
|
162
|
+
i = step.next
|
|
150
163
|
}
|
|
151
164
|
return out
|
|
152
165
|
}
|
|
@@ -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)
|
|
@@ -111,11 +122,11 @@ const powershellShell = (file: string): ResolvedShell => ({
|
|
|
111
122
|
*/
|
|
112
123
|
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
124
|
if (preferred === 'powershell') {
|
|
114
|
-
const powershell = resolvePowershellBinary(platform, env)
|
|
125
|
+
const powershell = resolvePowershellBinary(platform, env, cwd)
|
|
115
126
|
if (powershell) return powershellShell(powershell)
|
|
116
127
|
}
|
|
117
128
|
const bash = bashBinary(platform, env, cwd, installRoots)
|
|
118
129
|
if (bash) return bashShell(bash)
|
|
119
|
-
const powershell = resolvePowershellBinary(platform, env)
|
|
130
|
+
const powershell = resolvePowershellBinary(platform, env, cwd)
|
|
120
131
|
return powershell ? powershellShell(powershell) : undefined
|
|
121
132
|
}
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ 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
|
|
@@ -563,6 +563,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
563
563
|
if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
/** Publishes the current alias set and the connection banner. `'connecting'`
|
|
567
|
+
* servers are excluded from both connected and failed counts: they are neither,
|
|
568
|
+
* and while the non-blocking startup race below is still in flight some legitimately
|
|
569
|
+
* are. Called once right after startup (whether or not every server settled by
|
|
570
|
+
* then) and again when a still-connecting tail finishes, so a server that connects
|
|
571
|
+
* late still gets its aliases published and counted. */
|
|
572
|
+
function publishConnectionSummary(ctx: ExtensionContext): void {
|
|
573
|
+
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
574
|
+
const connected = [...status.values()].filter((s) => s.state === 'connected')
|
|
575
|
+
const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
|
|
576
|
+
if (connected.length > 0 || failed.length > 0) {
|
|
577
|
+
const total = connected.reduce((sum, s) => sum + s.tools, 0)
|
|
578
|
+
const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
|
|
579
|
+
ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
566
583
|
pi.on('session_start', async (_event, ctx) => {
|
|
567
584
|
// Reset the status map so /mcp and the banner reflect only this session's config: a
|
|
568
585
|
// server present last session but not this one must not linger as "connected". The
|
|
@@ -579,7 +596,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
579
596
|
projectConnected = false
|
|
580
597
|
// Claude answers roots/list with the session's launch directory and exports the
|
|
581
598
|
// 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 }
|
|
599
|
+
sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
|
|
583
600
|
const authUi = authUiFor(ctx)
|
|
584
601
|
sessionAuthUi = authUi
|
|
585
602
|
// The allow/deny lists filter every scope, including a managed-mcp.json set. They
|
|
@@ -592,21 +609,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
592
609
|
// file leaves the normal scopes untouched; a present but corrupt file fails closed to an
|
|
593
610
|
// empty set (see loadManagedMcpServers).
|
|
594
611
|
const managed = loadManagedMcpServers()
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
612
|
+
const connecting = managed !== null ? connectManagedExclusive(managed, policy, authUi) : connectNormalScopes(ctx, policy, authUi)
|
|
613
|
+
// Claude: "MCP startup is non-blocking by default: servers connect in the
|
|
614
|
+
// background and their tools become available as they finish." A slow or
|
|
615
|
+
// unreachable server no longer costs the whole session's startup its own
|
|
616
|
+
// MCP_TIMEOUT x retries; alwaysLoad-style forced waiting and the ToolSearch/
|
|
617
|
+
// WaitForMcpServers mechanism that lets the model itself wait on a pending
|
|
618
|
+
// server's tools are not implemented, so a call to a tool that has not
|
|
619
|
+
// registered yet still just finds no such tool, same as before it connected.
|
|
620
|
+
//
|
|
621
|
+
// Claude also states non-interactive mode "waits for still-pending servers
|
|
622
|
+
// before the first turn regardless of this variable", because a -p run has no
|
|
623
|
+
// later turn to react to late-arriving tools; a headless ctx (no UI) keeps the
|
|
624
|
+
// prior fully-blocking wait for the same reason.
|
|
625
|
+
const settled = !ctx.hasUI
|
|
626
|
+
? await connecting.then(() => true)
|
|
627
|
+
: await withTimeout(connecting, mcpConnectTimeoutMs(), 'mcp startup').then(
|
|
628
|
+
() => true,
|
|
629
|
+
() => false,
|
|
630
|
+
)
|
|
631
|
+
publishConnectionSummary(ctx)
|
|
632
|
+
if (!settled) void connecting.then(() => publishConnectionSummary(ctx))
|
|
610
633
|
})
|
|
611
634
|
|
|
612
635
|
pi.on('session_shutdown', async () => {
|
|
@@ -55,6 +55,12 @@ function envTimeout(name: string, fallback: number): number {
|
|
|
55
55
|
export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
56
56
|
export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
57
57
|
|
|
58
|
+
// Claude: "How long blocking MCP startup waits, in milliseconds, for the connection
|
|
59
|
+
// batch before snapshotting the tool list (default: 5000)." Distinct from MCP_TIMEOUT,
|
|
60
|
+
// which bounds one server's own connect attempt, not the whole startup batch.
|
|
61
|
+
const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 5000
|
|
62
|
+
export const mcpConnectTimeoutMs = (): number => envTimeout('MCP_CONNECT_TIMEOUT_MS', DEFAULT_MCP_CONNECT_TIMEOUT_MS)
|
|
63
|
+
|
|
58
64
|
/** Per-server inputs to the idle-window choice: the transport kind picks the default
|
|
59
65
|
* tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
|
|
60
66
|
export interface ServerCallTuning {
|
|
@@ -137,6 +143,9 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
|
137
143
|
export interface SessionDirs {
|
|
138
144
|
projectDir: string
|
|
139
145
|
launchDir: string
|
|
146
|
+
/** Claude: "An MCP server subprocess retains the ID it was spawned with", so this
|
|
147
|
+
* is captured once here at connect time rather than re-read per call. */
|
|
148
|
+
sessionId?: string
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
/** A client that, like Claude, declares the roots capability and answers roots/list
|
|
@@ -191,6 +200,7 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
|
|
|
191
200
|
const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
|
|
192
201
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
193
202
|
if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
|
|
203
|
+
if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
|
|
194
204
|
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
195
205
|
// The data dir is "created on first reference"; handing the path to a server is that
|
|
196
206
|
// reference, so the server does not have to mkdir it before using it.
|
package/extensions/question.ts
CHANGED
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import * as fs from 'node:fs'
|
|
10
|
+
import * as os from 'node:os'
|
|
11
|
+
import * as path from 'node:path'
|
|
9
12
|
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
10
13
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
11
14
|
import { Type } from 'typebox'
|
|
15
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
16
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
12
17
|
|
|
13
18
|
interface OptionWithDesc {
|
|
14
19
|
label: string
|
|
@@ -24,6 +29,8 @@ interface QuestionDetails {
|
|
|
24
29
|
answer: string | null
|
|
25
30
|
wasCustom?: boolean
|
|
26
31
|
multiSelect?: boolean
|
|
32
|
+
/** Auto-continued on askUserQuestionTimeout rather than answered or cancelled. */
|
|
33
|
+
timedOut?: boolean
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
// Options with labels and optional descriptions
|
|
@@ -70,6 +77,33 @@ function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec
|
|
|
70
77
|
const HEADER_MAX = 12
|
|
71
78
|
export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX))
|
|
72
79
|
|
|
80
|
+
/** Claude's three accepted askUserQuestionTimeout spellings (`60s`, `5m`, `10m`), as
|
|
81
|
+
* milliseconds. Anything else, including unset, means no auto-continue. */
|
|
82
|
+
export function parseAskUserQuestionTimeout(value: unknown): number | undefined {
|
|
83
|
+
if (typeof value !== 'string') return undefined
|
|
84
|
+
const match = /^(\d+)([sm])$/.exec(value.trim())
|
|
85
|
+
if (!match) return undefined
|
|
86
|
+
const amount = Number(match[1])
|
|
87
|
+
return match[2] === 's' ? amount * 1000 : amount * 60 * 1000
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Claude scopes askUserQuestionTimeout to "User or managed": a project's own
|
|
91
|
+
* settings.json cannot set it, so a checked-out repository can never make the
|
|
92
|
+
* user's own dialogs auto-answer themselves. Managed wins over the user's file, as
|
|
93
|
+
* every managed setting does. `home` defaults to the real one and is a parameter
|
|
94
|
+
* only so a test can point it at a fixture without mocking node:os. */
|
|
95
|
+
export function askUserQuestionTimeoutMs(home: string = os.homedir()): number | undefined {
|
|
96
|
+
const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown }
|
|
97
|
+
const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout)
|
|
98
|
+
if (fromManaged !== undefined) return fromManaged
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8')) as { askUserQuestionTimeout?: unknown }
|
|
101
|
+
return parseAskUserQuestionTimeout(parsed.askUserQuestionTimeout)
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
function checkbox(checked: boolean | undefined): string {
|
|
74
108
|
if (checked === undefined) return ''
|
|
75
109
|
return checked ? '[x] ' : '[ ] '
|
|
@@ -98,10 +132,13 @@ interface QuestionView {
|
|
|
98
132
|
checked: boolean[]
|
|
99
133
|
editor: Editor
|
|
100
134
|
theme: Theme
|
|
135
|
+
/** Claude: "You see a countdown for the last 20 seconds." Undefined the rest of
|
|
136
|
+
* the idle window, and always when there is no configured timeout at all. */
|
|
137
|
+
countdownSeconds?: number
|
|
101
138
|
}
|
|
102
139
|
|
|
103
140
|
function buildQuestionLines(view: QuestionView): string[] {
|
|
104
|
-
const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
|
|
141
|
+
const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds } = view
|
|
105
142
|
const lines: string[] = []
|
|
106
143
|
const add = (s: string) => lines.push(truncateToWidth(s, width))
|
|
107
144
|
|
|
@@ -129,6 +166,9 @@ function buildQuestionLines(view: QuestionView): string[] {
|
|
|
129
166
|
|
|
130
167
|
lines.push('')
|
|
131
168
|
add(theme.fg('dim', navHint(editMode, multiSelect)))
|
|
169
|
+
if (countdownSeconds !== undefined) {
|
|
170
|
+
add(theme.fg('warning', ` Auto-continuing in ${countdownSeconds}s if idle · press any key to stay`))
|
|
171
|
+
}
|
|
132
172
|
add(theme.fg('accent', '─'.repeat(width)))
|
|
133
173
|
|
|
134
174
|
return lines
|
|
@@ -202,6 +242,11 @@ export default function question(pi: ExtensionAPI) {
|
|
|
202
242
|
return new Text(theme.fg('warning', 'Cancelled'), 0, 0)
|
|
203
243
|
}
|
|
204
244
|
|
|
245
|
+
if (details.timedOut) {
|
|
246
|
+
const already = details.answer ? theme.fg('muted', ` (already selected: ${details.answer})`) : ''
|
|
247
|
+
return new Text(theme.fg('warning', '⏱ Auto-continued (no response)') + already, 0, 0)
|
|
248
|
+
}
|
|
249
|
+
|
|
205
250
|
if (details.wasCustom) {
|
|
206
251
|
return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
|
|
207
252
|
}
|
|
@@ -240,8 +285,10 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
240
285
|
|
|
241
286
|
// ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
|
|
242
287
|
// undefined immediately, which would read as a cancel without ever asking. Ask
|
|
243
|
-
// through the dialog primitives there instead.
|
|
244
|
-
|
|
288
|
+
// through the dialog primitives there instead. askUserQuestionTimeout is a TUI
|
|
289
|
+
// concept (a countdown, a keypress resetting it): the dialog-primitive fallback
|
|
290
|
+
// has no keyboard or visible countdown to drive it, so it is not applied there.
|
|
291
|
+
const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
245
292
|
|
|
246
293
|
// Build simple options list for details; header/multiSelect appear only when set,
|
|
247
294
|
// so single-select details are unchanged.
|
|
@@ -255,6 +302,18 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
255
302
|
}
|
|
256
303
|
}
|
|
257
304
|
|
|
305
|
+
if (result.timedOut) {
|
|
306
|
+
// Claude: "tells Claude you may be away from your keyboard, so Claude proceeds
|
|
307
|
+
// on its own judgment and can re-ask later." Not framed as a cancel: `answer` is
|
|
308
|
+
// '' rather than null, so renderResult and a batch's own null-check both read it
|
|
309
|
+
// as "answered nothing, but not declined" rather than the user having said no.
|
|
310
|
+
const already = multiSelect && result.answer ? ` Already selected: ${result.answer}.` : ''
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: 'text', text: `No response after the configured idle timeout; the user may be away from the keyboard.${already} Proceed on your own judgment; you can ask again later if needed.` }],
|
|
313
|
+
details: { ...base, answer: result.answer, timedOut: true } as QuestionDetails,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
258
317
|
if (result.wasCustom) {
|
|
259
318
|
return {
|
|
260
319
|
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
@@ -268,15 +327,82 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
268
327
|
}
|
|
269
328
|
}
|
|
270
329
|
|
|
271
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
330
|
+
/** Claude: "You see a countdown for the last 20 seconds." */
|
|
331
|
+
const COUNTDOWN_WINDOW_MS = 20_000
|
|
332
|
+
/** Granularity of the idle-timer tick: fine enough that the countdown's displayed
|
|
333
|
+
* second changes on time, coarse enough not to re-render needlessly often. */
|
|
334
|
+
const IDLE_TICK_MS = 250
|
|
335
|
+
|
|
336
|
+
/** Terminal path: the full custom overlay (options list, checkboxes, inline editor).
|
|
337
|
+
*
|
|
338
|
+
* `timeoutMs`, when set, is Claude's askUserQuestionTimeout: "After a question sits
|
|
339
|
+
* that long with no input, the dialog closes on its own: it submits any options
|
|
340
|
+
* you'd already selected and tells Claude you may be away from your keyboard, so
|
|
341
|
+
* Claude proceeds on its own judgment and can re-ask later. You see a countdown for
|
|
342
|
+
* the last 20 seconds. Press any key to restart the timer." Terminal focus-in
|
|
343
|
+
* restarting the timer, the other documented reset trigger, is not implemented:
|
|
344
|
+
* pi's TUI input stream is not confirmed to carry the terminal's own focus-report
|
|
345
|
+
* escape sequences, and guessing at that risks misreading ordinary input as a
|
|
346
|
+
* focus event on a terminal that reports it differently. Exported so the timer
|
|
347
|
+
* mechanics are testable directly, independent of where timeoutMs itself is read
|
|
348
|
+
* from (askUserQuestionTimeoutMs, tested separately).
|
|
349
|
+
*/
|
|
350
|
+
export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
351
|
+
return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
|
|
274
352
|
let optionIndex = 0
|
|
275
353
|
let editMode = false
|
|
276
354
|
const checked: boolean[] = allOptions.map(() => false)
|
|
277
355
|
let cachedLines: string[] | undefined
|
|
278
356
|
let cachedWidth: number | undefined
|
|
279
357
|
|
|
358
|
+
// deadline stays undefined for the whole overlay life when no timeout is
|
|
359
|
+
// configured, so every idle-timer branch below is a no-op in that case.
|
|
360
|
+
let deadline: number | undefined = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined
|
|
361
|
+
let idleTimer: ReturnType<typeof setInterval> | undefined
|
|
362
|
+
let lastCountdown: number | undefined
|
|
363
|
+
|
|
364
|
+
function stopIdleTimer(): void {
|
|
365
|
+
if (idleTimer !== undefined) clearInterval(idleTimer)
|
|
366
|
+
idleTimer = undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Every exit path (an answer, a cancel, or the timeout itself) goes through
|
|
370
|
+
* here, so the interval can never outlive the overlay it belongs to. */
|
|
371
|
+
function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
|
|
372
|
+
stopIdleTimer()
|
|
373
|
+
done(value)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function resetIdleTimer(): void {
|
|
377
|
+
if (timeoutMs === undefined) return
|
|
378
|
+
deadline = Date.now() + timeoutMs
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function fireTimeout(): void {
|
|
382
|
+
// Claude: "submits any options you'd already selected". Single-select has
|
|
383
|
+
// nothing pre-committed (a selection only exists once Enter confirms it), so
|
|
384
|
+
// its timeout answer is empty rather than whatever option merely had focus.
|
|
385
|
+
const answer = multiSelect ? selectedLabels(allOptions, checked) : ''
|
|
386
|
+
finish({ answer, wasCustom: false, timedOut: true })
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (timeoutMs !== undefined) {
|
|
390
|
+
idleTimer = setInterval(() => {
|
|
391
|
+
if (deadline === undefined) return
|
|
392
|
+
const remainingMs = deadline - Date.now()
|
|
393
|
+
if (remainingMs <= 0) {
|
|
394
|
+
fireTimeout()
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const remainingSeconds = Math.ceil(remainingMs / 1000)
|
|
398
|
+
const nextCountdown = remainingMs <= COUNTDOWN_WINDOW_MS ? remainingSeconds : undefined
|
|
399
|
+
if (nextCountdown !== lastCountdown) {
|
|
400
|
+
lastCountdown = nextCountdown
|
|
401
|
+
refresh()
|
|
402
|
+
}
|
|
403
|
+
}, IDLE_TICK_MS)
|
|
404
|
+
}
|
|
405
|
+
|
|
280
406
|
const editorTheme: EditorTheme = {
|
|
281
407
|
borderColor: (s) => theme.fg('accent', s),
|
|
282
408
|
selectList: {
|
|
@@ -292,7 +418,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
292
418
|
editor.onSubmit = (value) => {
|
|
293
419
|
const trimmed = value.trim()
|
|
294
420
|
if (trimmed) {
|
|
295
|
-
|
|
421
|
+
finish({ answer: trimmed, wasCustom: true })
|
|
296
422
|
} else {
|
|
297
423
|
editMode = false
|
|
298
424
|
editor.setText('')
|
|
@@ -306,6 +432,11 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
306
432
|
}
|
|
307
433
|
|
|
308
434
|
function handleInput(data: string) {
|
|
435
|
+
// Claude: "Press any key to restart the timer." Every branch below returns
|
|
436
|
+
// through this function, so resetting unconditionally on entry covers all of
|
|
437
|
+
// them, including the ones (arrow keys, space) that never reach `finish`.
|
|
438
|
+
resetIdleTimer()
|
|
439
|
+
|
|
309
440
|
if (editMode) {
|
|
310
441
|
if (matchesKey(data, Key.escape)) {
|
|
311
442
|
editMode = false
|
|
@@ -337,7 +468,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
337
468
|
|
|
338
469
|
if (matchesKey(data, Key.enter)) {
|
|
339
470
|
if (multiSelect) {
|
|
340
|
-
|
|
471
|
+
finish({ answer: selectedLabels(allOptions, checked), wasCustom: false })
|
|
341
472
|
return
|
|
342
473
|
}
|
|
343
474
|
const selected = allOptions[optionIndex]
|
|
@@ -345,20 +476,20 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
345
476
|
editMode = true
|
|
346
477
|
refresh()
|
|
347
478
|
} else {
|
|
348
|
-
|
|
479
|
+
finish({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
|
|
349
480
|
}
|
|
350
481
|
return
|
|
351
482
|
}
|
|
352
483
|
|
|
353
484
|
if (matchesKey(data, Key.escape)) {
|
|
354
|
-
|
|
485
|
+
finish(null)
|
|
355
486
|
}
|
|
356
487
|
}
|
|
357
488
|
|
|
358
489
|
function render(width: number): string[] {
|
|
359
490
|
if (cachedLines && cachedWidth === width) return cachedLines
|
|
360
491
|
cachedWidth = width
|
|
361
|
-
cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
|
|
492
|
+
cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds: lastCountdown })
|
|
362
493
|
return cachedLines
|
|
363
494
|
}
|
|
364
495
|
|
|
@@ -369,6 +500,10 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
369
500
|
cachedLines = undefined
|
|
370
501
|
},
|
|
371
502
|
handleInput,
|
|
503
|
+
// Belt and suspenders alongside finish()'s own stopIdleTimer: if the host ever
|
|
504
|
+
// tears the overlay down through a path that does not go through `done`
|
|
505
|
+
// (finish's only caller), the interval still gets cleared here.
|
|
506
|
+
dispose: stopIdleTimer,
|
|
372
507
|
}
|
|
373
508
|
})
|
|
374
509
|
}
|
|
@@ -376,7 +511,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
|
|
|
376
511
|
/** Dialog-primitive fallback for UI without a terminal (RPC mode supports
|
|
377
512
|
* select/input/notify but not custom components). Mirrors the overlay's result
|
|
378
513
|
* shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */
|
|
379
|
-
async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
|
|
514
|
+
async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
380
515
|
const header = shortHeader(params.header)
|
|
381
516
|
const title = header ? `[${header}] ${params.question}` : params.question
|
|
382
517
|
// Number the labels: ctx.ui.select returns the chosen label string, so duplicate
|
|
@@ -93,6 +93,11 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
96
|
+
// Claude: "Set to 1 to disable automatic terminal title updates based on conversation
|
|
97
|
+
// context. In Agent SDK and claude -p sessions, this also skips the background
|
|
98
|
+
// small/fast-model request that generates the session title." setSessionName is pi's
|
|
99
|
+
// only title sink, so skipping the call here skips both effects at once.
|
|
100
|
+
if (process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE === '1') return
|
|
96
101
|
if (titled) return
|
|
97
102
|
// Never clobber an existing name: a user-chosen or resumed name wins.
|
|
98
103
|
if (pi.getSessionName?.()) return
|
package/extensions/skills.ts
CHANGED
|
@@ -50,7 +50,12 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
|
|
|
50
50
|
// Claude's precedence: enterprise (the skills directory beside the managed
|
|
51
51
|
// settings file) overrides personal, and personal overrides project; discovery
|
|
52
52
|
// here is first-match, so higher precedence goes first.
|
|
53
|
-
|
|
53
|
+
// Claude: "Set to 1 to skip loading skills from the system-wide managed skills
|
|
54
|
+
// directory. Useful for container or CI sessions that should not load
|
|
55
|
+
// operator-provisioned skills." The enterprise dir beside managed-settings.json is
|
|
56
|
+
// that directory here; personal and project skills are unaffected.
|
|
57
|
+
const enterprise = process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS === '1' ? [] : [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills')]
|
|
58
|
+
const candidates = [...enterprise, path.join(claudeConfigDir(home), 'skills')]
|
|
54
59
|
// Enabled plugins contribute their skills directories. pi's loader names a
|
|
55
60
|
// skill by its directory, so a plugin skill registers without Claude's
|
|
56
61
|
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|
|
@@ -12,6 +12,7 @@ import * as path from 'node:path'
|
|
|
12
12
|
|
|
13
13
|
import type { AgentRunRequest } from '../internal/agent-run.js'
|
|
14
14
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
15
|
+
import { sliceBytes } from '../internal/output-guard.js'
|
|
15
16
|
import { repoRoot } from '../internal/project-root.js'
|
|
16
17
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
17
18
|
import { type AgentConfig, type AgentMemoryScope, expandMcpToolPatterns, withPreloadedSkills } from './agents.js'
|
|
@@ -188,10 +189,24 @@ export function agentHooksEnv(agent: AgentConfig, agentId: string): Record<strin
|
|
|
188
189
|
return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
/** This string becomes the whole of one argv element to the spawned child
|
|
193
|
+
* (run.ts, `spawn(..., { shell: false })`). Linux's MAX_ARG_STRLEN, a per-argument
|
|
194
|
+
* limit distinct from the much larger total ARG_MAX, is 128KiB; confirmed on a real
|
|
195
|
+
* Linux host that a single argv string over it fails execve with E2BIG. Neither the
|
|
196
|
+
* model's task text nor a SubagentStart hook's additionalContext is capped
|
|
197
|
+
* upstream, so this is where the assembled string caps itself. The budget leaves
|
|
198
|
+
* headroom under the hard limit for the "Task: " prefix, the notice below, and
|
|
199
|
+
* platforms whose limit differs from Linux's. */
|
|
200
|
+
const TASK_ARGV_MAX_BYTES = 96 * 1024
|
|
201
|
+
|
|
191
202
|
/** The task argument with any SubagentStart hook context ahead of it, per Claude:
|
|
192
203
|
* "added to the subagent's context at the start of its conversation, before its
|
|
193
|
-
* first prompt".
|
|
204
|
+
* first prompt". Capped as one combined string, since either the context or the
|
|
205
|
+
* task alone can already be oversized. */
|
|
194
206
|
export function taskWithStartContext(task: string, contexts: string[]): string {
|
|
195
207
|
const context = contexts.filter(Boolean).join('\n')
|
|
196
|
-
|
|
208
|
+
const assembled = context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
|
|
209
|
+
if (Buffer.byteLength(assembled, 'utf-8') <= TASK_ARGV_MAX_BYTES) return assembled
|
|
210
|
+
const kept = sliceBytes(assembled, TASK_ARGV_MAX_BYTES)
|
|
211
|
+
return `${kept}\n\n[truncated: too long for the child process to receive]`
|
|
197
212
|
}
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* worktree; everything about how the child was configured lives in child.ts.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn } from 'node:child_process'
|
|
9
|
+
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
|
10
10
|
import { randomUUID } from 'node:crypto'
|
|
11
11
|
import * as fs from 'node:fs'
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
|
+
import type { Readable } from 'node:stream'
|
|
14
15
|
|
|
15
16
|
import type { AgentToolResult } from '@earendil-works/pi-agent-core'
|
|
16
17
|
import type { Message } from '@earendil-works/pi-ai'
|
|
@@ -123,6 +124,29 @@ function appendPartialNote(result: SingleResult): void {
|
|
|
123
124
|
if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** The pi child, or the error spawning it threw synchronously. Node normally
|
|
128
|
+
* reports a spawn failure through the child's async 'error' event, but some
|
|
129
|
+
* failures (confirmed on Linux: posix_spawn detects E2BIG immediately) throw at
|
|
130
|
+
* the spawn() call site instead; catching it here, in one place with an explicit
|
|
131
|
+
* return type, keeps the caller's non-null stdout/stderr narrowing that a bare
|
|
132
|
+
* try/catch around an inline spawn() call loses. */
|
|
133
|
+
function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
|
|
134
|
+
try {
|
|
135
|
+
return {
|
|
136
|
+
proc: spawn(command, args, {
|
|
137
|
+
...options,
|
|
138
|
+
shell: false,
|
|
139
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
141
|
+
// direct child orphans a build or dev server the agent started.
|
|
142
|
+
detached: true,
|
|
143
|
+
}),
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { error: error as Error }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
export async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
127
151
|
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
128
152
|
if (!agent) return runSingleAgentInner(options)
|
|
@@ -244,16 +268,17 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
244
268
|
|
|
245
269
|
const exitCode = await new Promise<number>((resolve) => {
|
|
246
270
|
const invocation = getPiInvocation(args)
|
|
247
|
-
const
|
|
271
|
+
const spawned = spawnChild(invocation.command, invocation.args, {
|
|
248
272
|
cwd: worktree?.dir ?? runCwd,
|
|
249
|
-
shell: false,
|
|
250
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
251
|
-
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
252
|
-
// direct child orphans a build or dev server the agent started.
|
|
253
|
-
detached: true,
|
|
254
273
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
255
274
|
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
|
|
256
275
|
})
|
|
276
|
+
if ('error' in spawned) {
|
|
277
|
+
if (!currentResult.stderr) currentResult.stderr = spawned.error.message
|
|
278
|
+
resolve(1)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
const proc = spawned.proc
|
|
257
282
|
let buffer = ''
|
|
258
283
|
let assistantTurns = 0
|
|
259
284
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
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",
|