pi-code 1.0.56 → 1.0.57
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/commands.ts +6 -12
- package/extensions/context-imports.ts +2 -10
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +4 -3
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +12 -2
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running the `!` command spans and `@path` references a command or skill body carries.
|
|
3
|
+
*
|
|
4
|
+
* Split from the parsing half: only commands.ts drives spans, while skills.ts and the
|
|
5
|
+
* subagent loader import parsing alone and have no business pulling a shell resolver in.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { bashBinary } from './shell-resolve.js'
|
|
12
|
+
import { splitSegments } from './shell-split.js'
|
|
13
|
+
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
14
|
+
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
15
|
+
|
|
16
|
+
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
17
|
+
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
18
|
+
* lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
|
|
19
|
+
* same way; leaving them bare let a projectDir like `Alex’s Projects` end the
|
|
20
|
+
* literal mid-path with a ParserError. sh's '\'' form must not be used here,
|
|
21
|
+
* since PowerShell would keep the backslash and reopen the string. */
|
|
22
|
+
export function powershellQuote(value: string): string {
|
|
23
|
+
return value.replaceAll(/['‘’‚‛]/g, '$&$&')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { resolvePowershellBinary } from './shell-resolve.js'
|
|
27
|
+
|
|
28
|
+
export interface SpanExec {
|
|
29
|
+
command: string
|
|
30
|
+
args: string[]
|
|
31
|
+
/** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
|
|
32
|
+
* native command's stderr from `& { } 2>&1`), asking the caller to append the
|
|
33
|
+
* exec result's stderr to its stdout instead. The sh path merges in-script and
|
|
34
|
+
* leaves this unset. */
|
|
35
|
+
mergeStreams?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
|
|
39
|
+
* (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
|
|
40
|
+
* with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
|
|
41
|
+
* comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
42
|
+
* invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
|
|
43
|
+
* group still merges stderr for real spans. */
|
|
44
|
+
function shSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
45
|
+
const quoted = projectDir.replaceAll("'", String.raw`'\''`)
|
|
46
|
+
return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
|
|
50
|
+
* merge a native command's stderr on a script block, so mergeStreams has the caller
|
|
51
|
+
* append it. The trailing exit forwards a failed native command's code, which pwsh
|
|
52
|
+
* -Command otherwise swallows (the process exited 0 and a failure never aborted the
|
|
53
|
+
* invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
|
|
54
|
+
* Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
|
|
55
|
+
* error text still reaches the model through the merged stderr. */
|
|
56
|
+
function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
|
|
57
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
58
|
+
return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The exec invocation for one injected span, honoring the `shell:` frontmatter per
|
|
63
|
+
* Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
|
|
64
|
+
* one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
|
|
65
|
+
* Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
|
|
66
|
+
* command runs ("requires bash"), an undeclared one falls to PowerShell, and with
|
|
67
|
+
* neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
|
|
68
|
+
* shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
|
|
69
|
+
* runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
|
|
70
|
+
* mergeStreams in the caller.
|
|
71
|
+
*
|
|
72
|
+
* The resolvers are parameters so a caller (or test) controls the lookups: the
|
|
73
|
+
* PowerShell one is passed as an imported binding, the bash one defaults to the
|
|
74
|
+
* platform rule.
|
|
75
|
+
*/
|
|
76
|
+
export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
|
|
77
|
+
if (shell === 'powershell') {
|
|
78
|
+
const binary = resolveBinary()
|
|
79
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
80
|
+
}
|
|
81
|
+
const bash = resolveBash()
|
|
82
|
+
if (bash !== undefined) return shSpan(bash, projectDir, script)
|
|
83
|
+
if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
|
|
84
|
+
const binary = resolveBinary()
|
|
85
|
+
if (binary !== undefined) return powershellSpan(binary, projectDir, script)
|
|
86
|
+
throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface FenceBlock {
|
|
90
|
+
start: number
|
|
91
|
+
end: number
|
|
92
|
+
/** A fence opened with ```! runs its content as one script; any other fence protects. */
|
|
93
|
+
exec: boolean
|
|
94
|
+
content: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
98
|
+
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
99
|
+
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
100
|
+
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
101
|
+
function fenceBlocks(body: string): FenceBlock[] {
|
|
102
|
+
const blocks: FenceBlock[] = []
|
|
103
|
+
let fence: Fence | null = null
|
|
104
|
+
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
105
|
+
let offset = 0
|
|
106
|
+
for (const line of body.split('\n')) {
|
|
107
|
+
const trimmed = line.trimStart()
|
|
108
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
109
|
+
const lineEnd = offset + line.length
|
|
110
|
+
if (fence === null && step.fence !== null) {
|
|
111
|
+
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
112
|
+
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
113
|
+
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
114
|
+
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
115
|
+
open = undefined
|
|
116
|
+
}
|
|
117
|
+
fence = step.fence
|
|
118
|
+
offset = lineEnd + 1
|
|
119
|
+
}
|
|
120
|
+
// An unterminated fence protects to the end of the body rather than executing.
|
|
121
|
+
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
122
|
+
return blocks
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Exit 1 is a normal result for Claude's documented search and comparison commands
|
|
126
|
+
* (no matches, files differ); exit 2 and up fails even for these. The PowerShell
|
|
127
|
+
* shell uses a different set, which "includes grep and git diff but not find or
|
|
128
|
+
* diff" (test/[ are bash builtins and do not apply there either). */
|
|
129
|
+
const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
|
|
130
|
+
const EXIT_ONE_OK_POWERSHELL = new Set(['grep', 'rg', 'egrep', 'fgrep'])
|
|
131
|
+
|
|
132
|
+
export type SpanShell = 'bash' | 'powershell'
|
|
133
|
+
|
|
134
|
+
const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
|
|
135
|
+
const words = segment.trim().split(/\s+/)
|
|
136
|
+
if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
|
|
137
|
+
return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
|
|
141
|
+
const segments = splitSegments(command)
|
|
142
|
+
if (segments.length === 0) return false
|
|
143
|
+
// A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
|
|
144
|
+
// result and the last segment is not the one that set the code: `cd nope && grep x`
|
|
145
|
+
// exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
|
|
146
|
+
// the exit benign whichever ran last. Without short-circuit operators the exit is
|
|
147
|
+
// the last segment's (a `|` pipeline exits with its final command, `;`/newline with
|
|
148
|
+
// the last statement), so the last segment decides.
|
|
149
|
+
if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
|
|
150
|
+
return isCarveoutSegment(segments.at(-1) ?? '', shell)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run one injected span. A failure aborts the whole invocation, as Claude
|
|
154
|
+
* documents: the model never sees a half-expanded body. */
|
|
155
|
+
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
156
|
+
const result = await exec(command)
|
|
157
|
+
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
158
|
+
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
159
|
+
// at the Bash timeout and that failure aborts the invocation.
|
|
160
|
+
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
161
|
+
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
162
|
+
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
163
|
+
}
|
|
164
|
+
return result.stdout.trimEnd()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
|
|
168
|
+
|
|
169
|
+
/** Read a `@path` reference, confined to the working directory. Returns undefined
|
|
170
|
+
* when the path escapes it or cannot be read, so the reference stays literal. */
|
|
171
|
+
function readReference(cwd: string, reference: string): string | undefined {
|
|
172
|
+
try {
|
|
173
|
+
// Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
|
|
174
|
+
// resolved path against an unresolved root rejects every legitimate read.
|
|
175
|
+
const root = fs.realpathSync(cwd)
|
|
176
|
+
// Confinement is checked after symlinks resolve: a lexical check passes a link
|
|
177
|
+
// that points outside the project, and the read would follow it.
|
|
178
|
+
const real = fs.realpathSync(path.resolve(cwd, reference))
|
|
179
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
180
|
+
if (!fs.statSync(real).isFile()) return undefined
|
|
181
|
+
return fs.readFileSync(real, 'utf-8')
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DynamicSpan {
|
|
188
|
+
start: number
|
|
189
|
+
end: number
|
|
190
|
+
run: () => Promise<string>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
|
|
194
|
+
* its output (recognized only at a word start), a ```! fenced block runs its lines
|
|
195
|
+
* as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
|
|
196
|
+
* inside plain fenced code blocks. A failed command rejects, aborting the
|
|
197
|
+
* invocation, per the skills docs.
|
|
198
|
+
*
|
|
199
|
+
* Every placeholder is located in the ORIGINAL body and the whole body is expanded
|
|
200
|
+
* in one pass, so a command's output (or a file's content) is inserted verbatim and
|
|
201
|
+
* never re-scanned for further placeholders. Re-scanning was both a parity break
|
|
202
|
+
* (Claude expands once) and a command-injection path: output of a `` ```! `` block
|
|
203
|
+
* such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
|
|
204
|
+
export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec, shell: SpanShell = 'bash'): Promise<string> {
|
|
205
|
+
const blocks = fenceBlocks(body)
|
|
206
|
+
const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
|
|
207
|
+
const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
|
|
208
|
+
// An inline span or @ ref inside a ```! block is part of that block's script, not a
|
|
209
|
+
// placeholder of its own; the block already covers those bytes.
|
|
210
|
+
const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
|
|
211
|
+
|
|
212
|
+
const spans: DynamicSpan[] = []
|
|
213
|
+
for (const block of blocks) {
|
|
214
|
+
if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!', shell) })
|
|
215
|
+
}
|
|
216
|
+
// `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
|
|
217
|
+
const bashPattern = /(^|\s)!`([^`]+)`/g
|
|
218
|
+
for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
|
|
219
|
+
if (literal(m.index)) continue
|
|
220
|
+
const [span, lead, command] = m
|
|
221
|
+
spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``, shell)) })
|
|
222
|
+
}
|
|
223
|
+
const atPattern = /(^|\s)@(\S+)/g
|
|
224
|
+
for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
|
|
225
|
+
if (literal(m.index)) continue
|
|
226
|
+
const [whole, lead, reference] = m
|
|
227
|
+
spans.push({
|
|
228
|
+
start: m.index,
|
|
229
|
+
end: m.index + whole.length,
|
|
230
|
+
run: async () => {
|
|
231
|
+
const content = readReference(cwd, reference)
|
|
232
|
+
return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
spans.sort((a, b) => a.start - b.start)
|
|
238
|
+
let out = ''
|
|
239
|
+
let cursor = 0
|
|
240
|
+
for (const span of spans) {
|
|
241
|
+
if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
|
|
242
|
+
out += body.slice(cursor, span.start) + (await span.run())
|
|
243
|
+
cursor = span.end
|
|
244
|
+
}
|
|
245
|
+
return out + body.slice(cursor)
|
|
246
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import * as fs from 'node:fs'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
|
+
import { isRecord } from './values.js'
|
|
16
|
+
|
|
15
17
|
/** The OS managed-settings.json path Claude Code documents per platform. */
|
|
16
18
|
export function managedSettingsPath(platform: NodeJS.Platform = process.platform): string {
|
|
17
19
|
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
@@ -42,16 +44,12 @@ function readOneSettingsFile(file: string): Record<string, unknown> {
|
|
|
42
44
|
return {}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
46
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
47
|
/** Claude's managed-settings.d merge rules: a later single value replaces, lists
|
|
50
48
|
* combine with duplicates removed, and nested blocks merge key by key with each
|
|
51
49
|
* key following these same rules. */
|
|
52
50
|
function mergeManagedKey(base: unknown, next: unknown): unknown {
|
|
53
51
|
if (Array.isArray(base) && Array.isArray(next)) return [...new Set([...base, ...next])]
|
|
54
|
-
if (
|
|
52
|
+
if (isRecord(base) && isRecord(next)) {
|
|
55
53
|
const merged: Record<string, unknown> = { ...base }
|
|
56
54
|
for (const [key, value] of Object.entries(next)) merged[key] = key in merged ? mergeManagedKey(merged[key], value) : value
|
|
57
55
|
return merged
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
import * as crypto from 'node:crypto'
|
|
16
16
|
import * as fs from 'node:fs'
|
|
17
17
|
import * as path from 'node:path'
|
|
18
|
-
|
|
19
18
|
import { claudeConfigDir } from './config-dir.js'
|
|
20
19
|
import { readManagedSettings } from './managed-settings.js'
|
|
20
|
+
import { errorMessage } from './values.js'
|
|
21
21
|
|
|
22
22
|
export interface InstalledPlugin {
|
|
23
23
|
name: string
|
|
@@ -43,7 +43,7 @@ function readJson(file: string): Record<string, unknown> {
|
|
|
43
43
|
} catch (error) {
|
|
44
44
|
// A manifest that does not parse leaves the plugin with no components at all, and
|
|
45
45
|
// settings that do not parse drop the enablement or configuration they carried.
|
|
46
|
-
console.warn(`pi-code-plugins: ignoring ${file}: ${
|
|
46
|
+
console.warn(`pi-code-plugins: ignoring ${file}: ${errorMessage(error)}`)
|
|
47
47
|
return {}
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* and the skill-shell policy all resolve their files through this one chain.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import * as fs from 'node:fs'
|
|
10
11
|
import * as path from 'node:path'
|
|
11
12
|
import { claudeConfigDir } from './config-dir.js'
|
|
12
13
|
import { repoRoot } from './project-root.js'
|
|
14
|
+
import { isRecord } from './values.js'
|
|
13
15
|
|
|
14
16
|
/** The user settings.json, then (only when `includeProject`) the project files by
|
|
15
17
|
* Claude's placement rules: the shared `.claude/settings.json` is read from the
|
|
@@ -28,3 +30,20 @@ export function claudeSettingsChain(cwd: string, home: string, includeProject: b
|
|
|
28
30
|
files.push(path.join(localDir, '.claude', 'settings.local.json'))
|
|
29
31
|
return files
|
|
30
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Every readable settings object in the chain, in order, so the last one a caller
|
|
35
|
+
* sees for a key is the one that wins. A file that is missing, unparseable, or not a
|
|
36
|
+
* JSON object is skipped: a corrupt settings.json must not end the chain, or the
|
|
37
|
+
* user-level values behind it would silently vanish along with it. Lazy, so a caller
|
|
38
|
+
* that stops early does not read the rest. */
|
|
39
|
+
export function* readSettingsChain(files: readonly string[]): Generator<Record<string, unknown>> {
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
let parsed: unknown
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
44
|
+
} catch {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (isRecord(parsed)) yield parsed
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes every extension here needed its own copy of: an error's message, a plain
|
|
3
|
+
* object check, whether a path is a directory, and the text of a message content. Each
|
|
4
|
+
* was written three to five times with the same body, and the error one appeared in
|
|
5
|
+
* seventeen files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/** The message of a thrown value, whatever was thrown. */
|
|
11
|
+
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
12
|
+
|
|
13
|
+
/** A JSON object, as opposed to null, an array, or a primitive. */
|
|
14
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether the path is a directory today. A missing or unreadable path is not one. */
|
|
19
|
+
export function isDirectory(target: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return fs.statSync(target).isDirectory()
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The text of a message or tool-result content, dropping thinking and tool parts. A
|
|
28
|
+
* plain-string content is already the text. The separator is the caller's: Claude's
|
|
29
|
+
* last_assistant_message concatenates, a hook payload joins with newlines, a title or a
|
|
30
|
+
* prompt snippet with spaces. */
|
|
31
|
+
export function contentText(content: unknown, separator = ''): string {
|
|
32
|
+
if (typeof content === 'string') return content
|
|
33
|
+
if (!Array.isArray(content)) return ''
|
|
34
|
+
return content
|
|
35
|
+
.filter((part): part is { type: 'text'; text: string } => isRecord(part) && part.type === 'text' && typeof part.text === 'string')
|
|
36
|
+
.map((part) => part.text)
|
|
37
|
+
.join(separator)
|
|
38
|
+
}
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { installedPlugins } from '../internal/plugins.js'
|
|
|
44
44
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
45
45
|
import { repoRoot } from '../internal/project-root.js'
|
|
46
46
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
47
|
+
import { errorMessage } from '../internal/values.js'
|
|
47
48
|
import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
48
49
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
49
50
|
import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
@@ -272,7 +273,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
272
273
|
// streaming, so mid-stream invocations queue as a follow-up turn.
|
|
273
274
|
pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
|
|
274
275
|
} catch (error) {
|
|
275
|
-
ctx.ui.notify(`${commandName}: ${
|
|
276
|
+
ctx.ui.notify(`${commandName}: ${errorMessage(error)}`, 'error')
|
|
276
277
|
}
|
|
277
278
|
},
|
|
278
279
|
})
|
|
@@ -286,7 +287,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
286
287
|
try {
|
|
287
288
|
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
288
289
|
} catch (error) {
|
|
289
|
-
console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${
|
|
290
|
+
console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${errorMessage(error)}`)
|
|
290
291
|
}
|
|
291
292
|
}
|
|
292
293
|
|
|
@@ -298,7 +299,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
298
299
|
try {
|
|
299
300
|
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
300
301
|
} catch (error) {
|
|
301
|
-
console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${
|
|
302
|
+
console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${errorMessage(error)}`)
|
|
302
303
|
}
|
|
303
304
|
})
|
|
304
305
|
} catch {
|
|
@@ -386,7 +387,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
386
387
|
status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
|
|
387
388
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
388
389
|
} catch (error) {
|
|
389
|
-
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${
|
|
390
|
+
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${errorMessage(error)}`)
|
|
390
391
|
}
|
|
391
392
|
})
|
|
392
393
|
} catch {
|
|
@@ -445,7 +446,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
445
446
|
if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
|
|
446
447
|
}
|
|
447
448
|
} catch (error) {
|
|
448
|
-
status.set(name, { state: `failed: ${
|
|
449
|
+
status.set(name, { state: `failed: ${errorMessage(error)}`, tools: 0 })
|
|
449
450
|
// Connected but failed after (tool listing hung or errored): left in the
|
|
450
451
|
// map, the client idles its process for the whole session and the
|
|
451
452
|
// duplicate-name guard blocks the name for every later attempt.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
7
|
+
import { errorMessage } from '../internal/values.js'
|
|
7
8
|
import type { McpPromptInfo } from './mapping.js'
|
|
8
9
|
import { callRequestOptions, withTimeout } from './transport.js'
|
|
9
10
|
|
|
@@ -76,7 +77,7 @@ export async function collectServerResourceEntries(entries: Array<Record<string,
|
|
|
76
77
|
try {
|
|
77
78
|
await collectResources(entries, name, client, budget)
|
|
78
79
|
} catch (error) {
|
|
79
|
-
entries.push({ server: name, error:
|
|
80
|
+
entries.push({ server: name, error: errorMessage(error) })
|
|
80
81
|
}
|
|
81
82
|
try {
|
|
82
83
|
await collectResourceTemplates(entries, name, client, budget)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
9
9
|
import { FileOAuthProvider, type OAuthServerConfig, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
|
|
10
|
+
import { errorMessage } from '../internal/values.js'
|
|
10
11
|
import { type AuthUi, connectWithTimeout, isUnauthorized, type MakeTransport, OAuthRequiredError } from './transport.js'
|
|
11
12
|
|
|
12
13
|
/** Browser logins are human-paced; a connect-sized timeout would cut them off. */
|
|
@@ -16,7 +17,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 180_000
|
|
|
16
17
|
* unchanged so its message is not doubled. */
|
|
17
18
|
function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
|
|
18
19
|
if (error instanceof OAuthRequiredError) return error
|
|
19
|
-
const detail =
|
|
20
|
+
const detail = errorMessage(error)
|
|
20
21
|
return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
|
|
21
22
|
}
|
|
22
23
|
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as path from 'node:path'
|
|
|
9
9
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
10
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
11
11
|
import { findNearestFile } from '../internal/project-root.js'
|
|
12
|
+
import { errorMessage } from '../internal/values.js'
|
|
12
13
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
13
14
|
|
|
14
15
|
export interface ProjectServerPolicy {
|
|
@@ -151,7 +152,13 @@ export function urlPatternMatches(pattern: string, url: string): boolean {
|
|
|
151
152
|
return wildcardRegExp(patternParts.path).test(urlParts.path ?? '/')
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
/** A config file is user- or repo-written JSON, so any field can hold any value. Only a
|
|
156
|
+
* string `url` makes a server remote: a config that carries some other value there is
|
|
157
|
+
* still gated by its command, and no policy entry is evaluated against a non-string. */
|
|
158
|
+
const configUrl = (config: ServerConfig): string | undefined => {
|
|
159
|
+
const url = (config as { url?: unknown }).url
|
|
160
|
+
return typeof url === 'string' ? url : undefined
|
|
161
|
+
}
|
|
155
162
|
const configArgv = (config: ServerConfig): string[] | undefined => {
|
|
156
163
|
const command = (config as { command?: string }).command
|
|
157
164
|
if (typeof command !== 'string') return undefined
|
|
@@ -227,7 +234,7 @@ export function loadManagedMcpServers(managedFile: string = managedSettingsFile(
|
|
|
227
234
|
} catch (error) {
|
|
228
235
|
// Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
|
|
229
236
|
// rather than reopening the user/project/plugin scopes.
|
|
230
|
-
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${
|
|
237
|
+
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${errorMessage(error)}`)
|
|
231
238
|
return {}
|
|
232
239
|
}
|
|
233
240
|
if (parsed === null || typeof parsed !== 'object') return {}
|
package/extensions/memory.ts
CHANGED
|
@@ -20,8 +20,9 @@ import { readManagedSettings } from './internal/managed-settings.js'
|
|
|
20
20
|
import { capForContext } from './internal/output-guard.js'
|
|
21
21
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
22
22
|
import { repoRoot } from './internal/project-root.js'
|
|
23
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
23
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
24
24
|
import { statToken } from './internal/stat-token.js'
|
|
25
|
+
import { errorMessage } from './internal/values.js'
|
|
25
26
|
|
|
26
27
|
export const INDEX_FILE = 'MEMORY.md'
|
|
27
28
|
|
|
@@ -152,7 +153,7 @@ export function migrateLegacyStore(cwd: string): void {
|
|
|
152
153
|
} catch (error) {
|
|
153
154
|
// A failed migration must not take down session start, but the session then has no
|
|
154
155
|
// memories while they sit under the old slug, which reads as having lost them.
|
|
155
|
-
console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${
|
|
156
|
+
console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${errorMessage(error)}; this session starts without those memories`)
|
|
156
157
|
}
|
|
157
158
|
return
|
|
158
159
|
}
|
|
@@ -236,7 +237,7 @@ function readMemory(dir: string, name: string): MemoryToolResult {
|
|
|
236
237
|
// Only a missing file is "no such memory"; anything else (a directory in its place, a
|
|
237
238
|
// permission problem) sends the model hunting for a name that is actually there.
|
|
238
239
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
239
|
-
return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${
|
|
240
|
+
return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${errorMessage(error)}` }], details: {} }
|
|
240
241
|
}
|
|
241
242
|
return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
|
|
242
243
|
}
|
|
@@ -257,7 +258,7 @@ async function deleteMemory(dir: string, indexPath: string, name: string): Promi
|
|
|
257
258
|
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
258
259
|
})
|
|
259
260
|
} catch (error) {
|
|
260
|
-
return { content: [{ type: 'text', text: `Memory delete failed: ${
|
|
261
|
+
return { content: [{ type: 'text', text: `Memory delete failed: ${errorMessage(error)}. Nothing was deleted.` }], details: {} }
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
@@ -350,15 +351,9 @@ export function memorySettingsFiles(cwd: string, home: string, approved: boolean
|
|
|
350
351
|
* managed policy settings win over every file, per Claude's settings precedence. */
|
|
351
352
|
export function readMemorySettings(files: string[], managed: Record<string, unknown> = readManagedSettings()): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
|
|
352
353
|
const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
|
|
353
|
-
for (const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (settings === null || typeof settings !== 'object') continue
|
|
357
|
-
if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
|
|
358
|
-
if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
|
|
359
|
-
} catch {
|
|
360
|
-
// missing or invalid settings file: skip
|
|
361
|
-
}
|
|
354
|
+
for (const settings of readSettingsChain(files)) {
|
|
355
|
+
if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
|
|
356
|
+
if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
|
|
362
357
|
}
|
|
363
358
|
if ('autoMemoryEnabled' in managed) merged.autoMemoryEnabled = managed.autoMemoryEnabled
|
|
364
359
|
if ('autoMemoryDirectory' in managed) merged.autoMemoryDirectory = managed.autoMemoryDirectory
|
|
@@ -478,7 +473,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
478
473
|
// Awaited here, not returned: the catch must see a queued write's rejection.
|
|
479
474
|
return await saveMemory(dir, indexPath, name, params.description, params.content)
|
|
480
475
|
} catch (error) {
|
|
481
|
-
return { content: [{ type: 'text' as const, text: `Memory save failed: ${
|
|
476
|
+
return { content: [{ type: 'text' as const, text: `Memory save failed: ${errorMessage(error)}. The index was left untouched.` }], details: {} }
|
|
482
477
|
} finally {
|
|
483
478
|
indexCache = null
|
|
484
479
|
}
|
|
@@ -520,7 +515,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
520
515
|
try {
|
|
521
516
|
result = setAutoMemoryEnabledSetting(home, next)
|
|
522
517
|
} catch (error) {
|
|
523
|
-
ctx.ui.notify(`Could not update auto memory: ${
|
|
518
|
+
ctx.ui.notify(`Could not update auto memory: ${errorMessage(error)}`, 'error')
|
|
524
519
|
return
|
|
525
520
|
}
|
|
526
521
|
if (!result.ok) {
|
|
@@ -24,13 +24,13 @@ 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
|
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
27
|
-
|
|
28
27
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
29
28
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
30
29
|
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
31
30
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
32
31
|
import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
33
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
32
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
33
|
+
import { isDirectory } from './internal/values.js'
|
|
34
34
|
|
|
35
35
|
export interface OutputStyle {
|
|
36
36
|
name: string
|
|
@@ -87,14 +87,6 @@ export function applyStyle(systemPrompt: string, style: OutputStyle): string {
|
|
|
87
87
|
return `${systemPrompt}\n\n${styleSection}`
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function isDirectory(target: string): boolean {
|
|
91
|
-
try {
|
|
92
|
-
return fs.statSync(target).isDirectory()
|
|
93
|
-
} catch {
|
|
94
|
-
return false
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
90
|
/**
|
|
99
91
|
* Existing `.claude/output-styles` directories, user first then project. The project
|
|
100
92
|
* directory is included only for trusted projects, since its style body is injected
|
|
@@ -159,13 +151,8 @@ export function settingsFiles(cwd: string, home: string, trusted: boolean): stri
|
|
|
159
151
|
export function readActiveStyleName(files: string[], managed: Record<string, unknown> = readManagedSettings()): string | undefined {
|
|
160
152
|
if (typeof managed.outputStyle === 'string') return managed.outputStyle
|
|
161
153
|
let name: string | undefined
|
|
162
|
-
for (const
|
|
163
|
-
|
|
164
|
-
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
165
|
-
if (typeof settings.outputStyle === 'string') name = settings.outputStyle
|
|
166
|
-
} catch {
|
|
167
|
-
// missing or invalid file: skip
|
|
168
|
-
}
|
|
154
|
+
for (const settings of readSettingsChain(files)) {
|
|
155
|
+
if (typeof settings.outputStyle === 'string') name = settings.outputStyle
|
|
169
156
|
}
|
|
170
157
|
return name
|
|
171
158
|
}
|
|
@@ -19,7 +19,7 @@ import { Key } from '@earendil-works/pi-tui'
|
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
20
|
|
|
21
21
|
import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
22
|
-
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
22
|
+
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, restoredPlanState, type TodoItem } from './utils.js'
|
|
23
23
|
|
|
24
24
|
// Tools
|
|
25
25
|
const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
|
|
@@ -433,14 +433,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
433
433
|
// rewind past a plan it would resurrect the abandoned plan and its tool restriction.
|
|
434
434
|
const entries = ctx.sessionManager.getBranch()
|
|
435
435
|
|
|
436
|
-
// Restore persisted state
|
|
437
|
-
|
|
436
|
+
// Restore persisted state. The entry is JSON on disk, so every field is checked
|
|
437
|
+
// before it is used: savedTools reaches pi.setActiveTools.
|
|
438
|
+
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: unknown } | undefined
|
|
439
|
+
const restored = restoredPlanState(planModeEntry?.data)
|
|
438
440
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
executionMode = planModeEntry.data.executing ?? executionMode
|
|
443
|
-
}
|
|
441
|
+
planModeEnabled = restored.enabled ?? planModeEnabled
|
|
442
|
+
todoItems = restored.todos ?? todoItems
|
|
443
|
+
executionMode = restored.executing ?? executionMode
|
|
444
444
|
publishPlanState()
|
|
445
445
|
|
|
446
446
|
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
@@ -456,7 +456,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
456
456
|
// across /reload and cost the session edit and write for good; applying the
|
|
457
457
|
// snapshot when plan mode is off would instead push a stale set over whatever
|
|
458
458
|
// pi has registered since, so it stays scoped to this branch.
|
|
459
|
-
savedTools =
|
|
459
|
+
savedTools = restored.savedTools ?? pi.getActiveTools()
|
|
460
460
|
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
461
461
|
// --plan enters plan mode without ever toggling, so nothing has persisted yet
|
|
462
462
|
// and a /reload would find no snapshot to restore from. Record it now, while
|