pi-code 1.0.52 → 1.0.54
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 +15 -4
- package/extensions/git-checkpoint.ts +24 -5
- package/extensions/hooks/decisions.ts +1 -7
- package/extensions/hooks/index.ts +55 -27
- package/extensions/hooks/matcher.ts +8 -6
- package/extensions/internal/bash-rules.ts +44 -0
- package/extensions/internal/command-file.ts +8 -1
- package/extensions/internal/effort.ts +13 -0
- package/extensions/internal/mcp-oauth.ts +19 -5
- package/extensions/internal/project-approval.ts +2 -0
- package/extensions/mcp/config.ts +13 -1
- package/extensions/mcp/index.ts +3 -1
- package/extensions/mcp/oauth-flow.ts +6 -0
- package/extensions/mcp/policy.ts +10 -2
- package/extensions/mcp/transport.ts +29 -6
- package/extensions/memory.ts +5 -1
- package/extensions/status-line.ts +4 -4
- package/extensions/subagent/index.ts +7 -2
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -43,10 +43,10 @@ import * as os from 'node:os'
|
|
|
43
43
|
import * as path from 'node:path'
|
|
44
44
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
45
45
|
import { Type } from 'typebox'
|
|
46
|
-
|
|
47
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
48
47
|
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
49
48
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
49
|
+
import { claudeEffortLevel } from './internal/effort.js'
|
|
50
50
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
51
51
|
import { capForContext } from './internal/output-guard.js'
|
|
52
52
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
@@ -174,10 +174,19 @@ export function shellExecutionDisabled(cwd: string, home: string, trusted: boole
|
|
|
174
174
|
if (readManagedSettings().disableSkillShellExecution === true) return true
|
|
175
175
|
const files = claudeSettingsChain(cwd, home, trusted)
|
|
176
176
|
return files.some((file) => {
|
|
177
|
+
let raw: string
|
|
178
|
+
try {
|
|
179
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
180
|
+
} catch {
|
|
181
|
+
return false // no such file: genuinely not a policy statement
|
|
182
|
+
}
|
|
177
183
|
try {
|
|
178
|
-
return (JSON.parse(
|
|
184
|
+
return (JSON.parse(raw) as Record<string, unknown>).disableSkillShellExecution === true
|
|
179
185
|
} catch {
|
|
180
|
-
|
|
186
|
+
// Present but unreadable: the user may believe this file disables skill shells, so
|
|
187
|
+
// say so rather than silently leaving them enabled.
|
|
188
|
+
console.warn(`pi-code: ignoring ${file}: not valid JSON; skill shell execution stays as the other settings leave it`)
|
|
189
|
+
return false
|
|
181
190
|
}
|
|
182
191
|
})
|
|
183
192
|
}
|
|
@@ -187,7 +196,9 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
|
|
|
187
196
|
const varCtx = ctx as unknown as VarContext
|
|
188
197
|
return {
|
|
189
198
|
CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
|
|
190
|
-
|
|
199
|
+
// Claude vocabulary only, and unset when thinking is off: the variable promises
|
|
200
|
+
// low|medium|high|xhigh|max, and an unset one stays literal in the body.
|
|
201
|
+
CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
|
|
191
202
|
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
192
203
|
CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
|
|
193
204
|
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
@@ -18,6 +18,7 @@ import * as fs from 'node:fs'
|
|
|
18
18
|
import * as os from 'node:os'
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
21
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
21
22
|
|
|
22
23
|
const CUSTOM_TYPE = 'git-checkpoint'
|
|
23
24
|
/** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
|
|
@@ -37,6 +38,21 @@ interface Checkpoint {
|
|
|
37
38
|
* for the life of the machine. */
|
|
38
39
|
export const CHECKPOINT_RETENTION_DAYS = 30
|
|
39
40
|
|
|
41
|
+
/** The retention period in effect: Claude keeps checkpoints for 30 days and says to
|
|
42
|
+
* "change the period with cleanupPeriodDays". Read from the user scope, which is where a
|
|
43
|
+
* setting about the user's own disk belongs; a non-positive or unreadable value keeps the
|
|
44
|
+
* default rather than sweeping everything away. */
|
|
45
|
+
export function checkpointRetentionDays(home: string = os.homedir()): number {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
48
|
+
const declared = (parsed as { cleanupPeriodDays?: unknown }).cleanupPeriodDays
|
|
49
|
+
if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
|
|
50
|
+
} catch {
|
|
51
|
+
// No user settings, or unreadable: the default period stands.
|
|
52
|
+
}
|
|
53
|
+
return CHECKPOINT_RETENTION_DAYS
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
/** Claude keeps the 100 most recent checkpoints per session. Older ones drop off the
|
|
41
57
|
* rewind list; their commits stay in the shadow repo until the retention sweep. */
|
|
42
58
|
export const MAX_CHECKPOINTS_PER_SESSION = 100
|
|
@@ -179,7 +195,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
179
195
|
shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
|
|
180
196
|
ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
|
|
181
197
|
}
|
|
182
|
-
pruneCheckpointRepos(checkpointsRoot,
|
|
198
|
+
pruneCheckpointRepos(checkpointsRoot, checkpointRetentionDays(os.homedir()), shadowDir)
|
|
183
199
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
184
200
|
if (check.code !== 0) {
|
|
185
201
|
const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
@@ -194,7 +210,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
194
210
|
}
|
|
195
211
|
// Written on every start, so repos that predate the sidecar pick it up too.
|
|
196
212
|
rememberWorkTree(shadowDir, ctx.cwd)
|
|
197
|
-
await mirrorLocalExcludes(ctx
|
|
213
|
+
await mirrorLocalExcludes(ctx)
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
/** git reads ignore rules from the tree's .gitignore files, the user's global excludes,
|
|
@@ -203,7 +219,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
203
219
|
* would be snapshotted and restored. Mirror it into the shadow on every start; the
|
|
204
220
|
* global excludes stay untouched (core.excludesFile is single-valued, so pointing it
|
|
205
221
|
* at the repo file would replace them). */
|
|
206
|
-
async function mirrorLocalExcludes(
|
|
222
|
+
async function mirrorLocalExcludes(ctx: ExtensionContext): Promise<void> {
|
|
223
|
+
const cwd = ctx.cwd
|
|
207
224
|
if (!shadowDir) return
|
|
208
225
|
// Resolved through git so a linked worktree maps to its common dir; outside a repo
|
|
209
226
|
// git exits 128 and there is nothing to mirror.
|
|
@@ -217,8 +234,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
217
234
|
} else {
|
|
218
235
|
fs.rmSync(target, { force: true })
|
|
219
236
|
}
|
|
220
|
-
} catch {
|
|
221
|
-
//
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// Without the mirror, files the user excluded locally are snapshotted into the
|
|
239
|
+
// checkpoint store and restored by /rewind, so this is not a silent fallback.
|
|
240
|
+
ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
|
|
@@ -262,8 +262,6 @@ export interface PromptDecision {
|
|
|
262
262
|
block: boolean
|
|
263
263
|
reason?: string
|
|
264
264
|
context: string
|
|
265
|
-
/** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
|
|
266
|
-
suppress?: boolean
|
|
267
265
|
}
|
|
268
266
|
|
|
269
267
|
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
@@ -291,17 +289,13 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
291
289
|
}
|
|
292
290
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
293
291
|
const contexts: string[] = []
|
|
294
|
-
let suppress = false
|
|
295
292
|
for (const result of results) {
|
|
296
293
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
297
294
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
298
|
-
// Claude's suppressOriginalPrompt: any hook setting it hides the original
|
|
299
|
-
// prompt, and the collected context is what reaches the model.
|
|
300
|
-
if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
|
|
301
295
|
const context = promptContext(result.stdout)
|
|
302
296
|
if (context) contexts.push(context)
|
|
303
297
|
}
|
|
304
|
-
return { block: false, context: contexts.join('\n')
|
|
298
|
+
return { block: false, context: contexts.join('\n') }
|
|
305
299
|
}
|
|
306
300
|
|
|
307
301
|
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
|
|
97
97
|
import * as os from 'node:os'
|
|
98
98
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
99
|
+
import { claudeEffortLevel } from '../internal/effort.js'
|
|
99
100
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
100
101
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
101
102
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
@@ -186,6 +187,25 @@ const IDLE_PROMPT_DELAY_MS = 60_000
|
|
|
186
187
|
* Claude Code restores on resume. */
|
|
187
188
|
const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
|
|
188
189
|
|
|
190
|
+
/** One Stop hook result read as a verdict. Claude: `continue` "takes precedence over any
|
|
191
|
+
* event-specific decision fields", and stopReason is the message shown when it is false,
|
|
192
|
+
* so a hook asking to stop wins over its own block whatever exit code carried it. Any
|
|
193
|
+
* blocking spelling counts, including the prompt and agent hook reply schemas, and a
|
|
194
|
+
* non-error additionalContext feeds back the same way so the block cap still bounds it. */
|
|
195
|
+
function stopVerdict(result: HookRunResult, stopMessages: string[]): { block: boolean; reason: string } {
|
|
196
|
+
const parsed = tryParseJson(result.stdout)
|
|
197
|
+
if (parsed?.continue === false) {
|
|
198
|
+
if (parsed.stopReason) stopMessages.push(String(parsed.stopReason))
|
|
199
|
+
return { block: false, reason: '' }
|
|
200
|
+
}
|
|
201
|
+
if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
|
|
202
|
+
const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
|
|
203
|
+
if (verdict) return { block: true, reason: verdict.reason }
|
|
204
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
205
|
+
if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
|
|
206
|
+
return { block: false, reason: '' }
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
190
210
|
let config: HooksConfig = {}
|
|
191
211
|
let projectDir = ''
|
|
@@ -228,7 +248,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
228
248
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
229
249
|
const transcript = ctx.sessionManager.getSessionFile()
|
|
230
250
|
if (transcript) common.transcript_path = transcript
|
|
231
|
-
|
|
251
|
+
// Claude vocabulary only: pi's minimal maps to low and off carries no effort.
|
|
252
|
+
const effort = claudeEffortLevel(ctx.thinkingLevel)
|
|
253
|
+
if (effort) common.effort = { level: effort }
|
|
232
254
|
return common
|
|
233
255
|
}
|
|
234
256
|
/** Claude's prompt-hook `model` override, resolved against the models this user
|
|
@@ -512,7 +534,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
512
534
|
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
513
535
|
const startedAt = toolStartTimes.get(event.toolCallId)
|
|
514
536
|
toolStartTimes.delete(event.toolCallId)
|
|
515
|
-
|
|
537
|
+
// Claude delivers a failure as top-level fields rather than a tool_response: "error
|
|
538
|
+
// information as top-level fields ... error ... is_interrupt". is_interrupt is false
|
|
539
|
+
// here because pi reports a cancelled tool through the result, not this event.
|
|
540
|
+
const failure = event.isError ? { error: textContent(event.content), is_interrupt: false } : { tool_response: response }
|
|
541
|
+
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
|
|
516
542
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
517
543
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
518
544
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
@@ -576,10 +602,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
576
602
|
return { action: 'handled' }
|
|
577
603
|
}
|
|
578
604
|
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
579
|
-
// pi's seam for rewriting the submitted text.
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
605
|
+
// pi's seam for rewriting the submitted text. The prompt itself always survives:
|
|
606
|
+
// "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
|
|
607
|
+
// alongside it". suppressOriginalPrompt scopes to the block message, which never
|
|
608
|
+
// carries the prompt here, so it needs nothing of its own.
|
|
609
|
+
if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
|
|
583
610
|
return { action: 'continue' }
|
|
584
611
|
})
|
|
585
612
|
|
|
@@ -593,12 +620,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
593
620
|
// handler on a UI dialog, which would starve the Stop hook and idle notification
|
|
594
621
|
// until the user answers it. agent_end can fire slightly early before a rare
|
|
595
622
|
// automatic retry or compaction; that is the better tradeoff.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
// codes and JSON output are ignored, as Claude documents for this event.
|
|
623
|
+
/** Claude's Notification event, for the one type pi can honestly source: the agent
|
|
624
|
+
* finished and is waiting for input. idle_prompt fires when the turn ended about 60
|
|
625
|
+
* seconds ago and the user has not typed since, so it arms here and input or the next
|
|
626
|
+
* turn cancels it. Observational only; exit codes and JSON output are ignored. */
|
|
627
|
+
const armIdlePrompt = (ctx: ExtensionContext): void => {
|
|
602
628
|
cancelIdlePrompt()
|
|
603
629
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
604
630
|
if (notifyCommands.length > 0) {
|
|
@@ -608,6 +634,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
608
634
|
}, IDLE_PROMPT_DELAY_MS)
|
|
609
635
|
idlePromptTimer.unref?.()
|
|
610
636
|
}
|
|
637
|
+
}
|
|
638
|
+
pi.on('agent_end', async (event, ctx) => {
|
|
639
|
+
armIdlePrompt(ctx)
|
|
611
640
|
|
|
612
641
|
// In a subagent child, the agent-frontmatter Stop hooks were converted to
|
|
613
642
|
// SubagentStop and fire here, at the child's own end, notify-style; before the
|
|
@@ -643,24 +672,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
643
672
|
const run = boundRunner(ctx)
|
|
644
673
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
645
674
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
675
|
+
const stopMessages: string[] = []
|
|
646
676
|
const block = results
|
|
647
677
|
.filter((result) => !result.timedOut)
|
|
648
|
-
.map((result) =>
|
|
649
|
-
const parsed = tryParseJson(result.stdout)
|
|
650
|
-
if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
|
|
651
|
-
// Any JSON blocking spelling counts, including the prompt/agent hook reply
|
|
652
|
-
// schemas (permissionDecision deny, ok:false), which arrive as stdout here.
|
|
653
|
-
const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
|
|
654
|
-
if (verdict) return { block: true, reason: verdict.reason }
|
|
655
|
-
// Claude's non-error continue: additionalContext feeds back and the
|
|
656
|
-
// conversation continues so Claude can act on it. It rides the same
|
|
657
|
-
// continuation path (and the same block cap) so a hook emitting it every
|
|
658
|
-
// firing cannot loop the turn forever.
|
|
659
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
660
|
-
if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
|
|
661
|
-
return { block: false, reason: '' }
|
|
662
|
-
})
|
|
678
|
+
.map((result) => stopVerdict(result, stopMessages))
|
|
663
679
|
.find((verdict) => verdict.block)
|
|
680
|
+
for (const message of stopMessages) ctx.ui.notify(message, 'warning')
|
|
664
681
|
if (!block) {
|
|
665
682
|
// A non-blocking Stop breaks the streak: the next block starts a fresh count.
|
|
666
683
|
stopHookActive = false
|
|
@@ -689,6 +706,17 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
689
706
|
const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
|
|
690
707
|
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
|
|
691
708
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
709
|
+
// Claude: "Exit with code 2 to block compaction. For a manual /compact, the stderr
|
|
710
|
+
// message is shown to the user. You can also block by returning JSON with
|
|
711
|
+
// `decision: block`." pi cancels through the result, and a blocked automatic
|
|
712
|
+
// compaction is worth a notice too: the context stays full either way.
|
|
713
|
+
for (const [index, result] of results.entries()) {
|
|
714
|
+
const parsed = tryParseJson(result.stdout)
|
|
715
|
+
const blocked = result.code === 2 && !result.timedOut ? { reason: result.stderr.trim() || 'Compaction blocked by hook' } : jsonBlockVerdict(parsed, 'Compaction blocked by hook')
|
|
716
|
+
if (!blocked) continue
|
|
717
|
+
ctx.ui.notify(`Compaction blocked by ${matchingCommands(config.PreCompact, trigger.names)[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
|
|
718
|
+
return { cancel: true }
|
|
719
|
+
}
|
|
692
720
|
})
|
|
693
721
|
|
|
694
722
|
pi.on('session_compact', async (event, ctx) => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* commands an event fires. Owns the module-level compiled-matcher cache.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
8
8
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
9
9
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
10
10
|
|
|
@@ -159,14 +159,16 @@ export interface IfFilterTarget {
|
|
|
159
159
|
|
|
160
160
|
/** Claude's `if` handler field: permission-rule syntax evaluated only on tool
|
|
161
161
|
* events; on any other event a hook carrying `if` never runs. A bare tool name
|
|
162
|
-
* matches by name; `Bash(pattern)` evaluates against the command
|
|
163
|
-
*
|
|
164
|
-
* permission path rules. A pattern for any other tool matches nothing, which is
|
|
162
|
+
* matches by name; `Bash(pattern)` evaluates against the command through the if-filter
|
|
163
|
+
* matcher, which is best effort and errs toward running the hook, and file-tool patterns
|
|
164
|
+
* against the path via the shared permission path rules. A pattern for any other tool matches nothing, which is
|
|
165
165
|
* also what an unparseable rule does. */
|
|
166
166
|
export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undefined): boolean {
|
|
167
167
|
if (hook.if === undefined) return true
|
|
168
168
|
if (target === undefined) return false
|
|
169
|
-
|
|
169
|
+
// Digits and hyphens are part of a tool name: an MCP tool is mcp__<server>__<tool> and
|
|
170
|
+
// server names carry both, so a stricter class silently matched nothing.
|
|
171
|
+
const parsed = /^([\w|-]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
|
|
170
172
|
if (!parsed) return false
|
|
171
173
|
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
172
174
|
const ruleTools = new Set(parsed[1].split('|').map(fold))
|
|
@@ -177,7 +179,7 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
|
|
|
177
179
|
const input = target.input as Record<string, unknown> | null
|
|
178
180
|
if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
|
|
179
181
|
const command = typeof input?.command === 'string' ? input.command : ''
|
|
180
|
-
return command.length > 0 &&
|
|
182
|
+
return command.length > 0 && matchesBashIfFilter(command, pattern)
|
|
181
183
|
}
|
|
182
184
|
let filePath = ''
|
|
183
185
|
if (typeof input?.path === 'string') filePath = input.path
|
|
@@ -30,6 +30,50 @@ function matchesRule(segment: string, rule: string): boolean {
|
|
|
30
30
|
return segment === normalized
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Leading `VAR=value` assignments, which Claude strips before matching an `if` pattern. */
|
|
34
|
+
const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/
|
|
35
|
+
|
|
36
|
+
/** The bodies of `$(...)`, `` `...` ``, `<(...)` and `>(...)`, which run commands of their own. */
|
|
37
|
+
function substitutionBodies(command: string): string[] {
|
|
38
|
+
const bodies: string[] = []
|
|
39
|
+
for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`|[<>]\(([^()]*)\)/g)) {
|
|
40
|
+
const body = match[1] ?? match[2] ?? match[3]
|
|
41
|
+
if (body?.trim()) bodies.push(body.trim())
|
|
42
|
+
}
|
|
43
|
+
return bodies
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether the first word of a segment is something only the shell can resolve. */
|
|
47
|
+
const headUnresolvable = (segment: string): boolean => /[$`]/.test(segment.split(/\s+/, 1)[0] ?? '')
|
|
48
|
+
|
|
49
|
+
/** Whether a pattern names more than the command itself, like `git push *` against `git *`. */
|
|
50
|
+
const namesMoreThanCommand = (rule: string): boolean => rule.split('*', 1)[0].trim().includes(' ')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Claude's `if` filter for a Bash call, which decides whether a hook gets to SEE the
|
|
54
|
+
* call. That makes it the mirror of a permission rule: a grant must hold for every
|
|
55
|
+
* segment and fails closed on anything it cannot read, while this runs the hook when any
|
|
56
|
+
* segment matches and when the input cannot be resolved at all. Claude: "When Claude Code
|
|
57
|
+
* can't determine which commands the Bash input runs, it runs your hook regardless of the
|
|
58
|
+
* pattern. Because the `if` filter is best-effort, use the permission system rather than
|
|
59
|
+
* a hook to enforce a hard allow or deny."
|
|
60
|
+
*
|
|
61
|
+
* Each top-level segment is checked with its leading assignments stripped, and so is the
|
|
62
|
+
* body of every substitution, since one can sit at any argument position. An unresolvable
|
|
63
|
+
* command name runs the hook whatever the pattern; a substitution anywhere runs it when
|
|
64
|
+
* the pattern names more than the command.
|
|
65
|
+
*/
|
|
66
|
+
export function matchesBashIfFilter(command: string, rule: string): boolean {
|
|
67
|
+
const trimmed = rule.trim()
|
|
68
|
+
const candidates = splitSegments(command).flatMap((segment) => {
|
|
69
|
+
const stripped = segment.replace(LEADING_ASSIGNMENTS, '')
|
|
70
|
+
return [stripped, ...substitutionBodies(stripped).flatMap((body) => splitSegments(body).map((inner) => inner.replace(LEADING_ASSIGNMENTS, '')))]
|
|
71
|
+
})
|
|
72
|
+
if (candidates.some((candidate) => matchesRule(candidate, trimmed))) return true
|
|
73
|
+
if (candidates.some(headUnresolvable)) return true
|
|
74
|
+
return namesMoreThanCommand(trimmed) && (hasSubstitution(command) || /\$[A-Za-z_{]/.test(command))
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
export function matchesBashRules(command: string, rules: string[]): boolean {
|
|
34
78
|
if (hasSubstitution(command)) return false
|
|
35
79
|
const segments = splitSegments(command)
|
|
@@ -377,7 +377,14 @@ export function substituteArgsDetailed(body: string, args: string, names: string
|
|
|
377
377
|
return all || argsDefault
|
|
378
378
|
}
|
|
379
379
|
if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
|
|
380
|
-
if (name !== undefined)
|
|
380
|
+
if (name !== undefined) {
|
|
381
|
+
// Claude: "A named placeholder counts even when its position has no argument,
|
|
382
|
+
// because it expands to an empty string", unlike an indexed one, which stays
|
|
383
|
+
// literal and does not count. Otherwise a skill using named arguments still got
|
|
384
|
+
// the ARGUMENTS: block appended.
|
|
385
|
+
consumed = true
|
|
386
|
+
return fill(parts[names.indexOf(name)], '')
|
|
387
|
+
}
|
|
381
388
|
consumed = true
|
|
382
389
|
return all // $ARGUMENTS or $@
|
|
383
390
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's thinking levels translated to Claude's effort vocabulary, which is
|
|
3
|
+
* `low | medium | high | xhigh | max`. pi adds two levels outside it: `minimal`, which
|
|
4
|
+
* reads as Claude's lowest, and `off`, which has no effort at all. Every surface that
|
|
5
|
+
* exposes the level to a script or a hook goes through here, so `off` and `minimal`
|
|
6
|
+
* never leak into a payload or an environment variable that promises Claude's set.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The Claude effort level for a pi thinking level, or undefined when thinking is off. */
|
|
10
|
+
export function claudeEffortLevel(thinkingLevel: string | undefined): string | undefined {
|
|
11
|
+
if (!thinkingLevel || thinkingLevel === 'off') return undefined
|
|
12
|
+
return thinkingLevel === 'minimal' ? 'low' : thinkingLevel
|
|
13
|
+
}
|
|
@@ -121,8 +121,11 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** The localhost spelling, which is what a server with a pre-registered redirect URI
|
|
125
|
+
* expects: Claude sent the 127.0.0.1 form for one version and "servers that exact-match
|
|
126
|
+
* the registered redirect URI rejected the sign-in with a redirect URI mismatch". */
|
|
124
127
|
get redirectUrl(): string {
|
|
125
|
-
return `http://
|
|
128
|
+
return `http://localhost:${this.port}/callback`
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
get clientMetadata(): OAuthClientMetadata {
|
|
@@ -193,13 +196,24 @@ export class FileOAuthProvider implements OAuthClientProvider {
|
|
|
193
196
|
* taken, an ephemeral port is used. */
|
|
194
197
|
export async function startCallbackServer(preferredPort?: number): Promise<{ server: http.Server; port: number }> {
|
|
195
198
|
const server = http.createServer()
|
|
196
|
-
const listen = (port: number): Promise<void> => new Promise((resolve, reject) => server.listen(port,
|
|
199
|
+
const listen = (port: number, host: string): Promise<void> => new Promise((resolve, reject) => server.listen(port, host, resolve).once('error', reject))
|
|
197
200
|
try {
|
|
198
|
-
await listen(preferredPort ?? 0)
|
|
201
|
+
await listen(preferredPort ?? 0, '127.0.0.1')
|
|
199
202
|
} catch {
|
|
200
|
-
await listen(0)
|
|
203
|
+
await listen(0, '127.0.0.1')
|
|
201
204
|
}
|
|
202
|
-
|
|
205
|
+
const port = (server.address() as { port: number }).port
|
|
206
|
+
// The redirect names localhost, which resolves to ::1 first on a host with IPv6, so a
|
|
207
|
+
// second listener answers there too. Best effort: where it cannot bind, the IPv4
|
|
208
|
+
// listener above still answers every browser that resolves localhost to 127.0.0.1.
|
|
209
|
+
const ipv6 = http.createServer()
|
|
210
|
+
ipv6.on('error', () => {})
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
ipv6.listen(port, '::1', () => resolve()).once('error', () => resolve())
|
|
213
|
+
})
|
|
214
|
+
server.on('close', () => ipv6.close())
|
|
215
|
+
ipv6.on('request', (request, response) => server.emit('request', request, response))
|
|
216
|
+
return { server, port }
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
export function waitForAuthCode(server: http.Server, timeoutMs: number, expectedState?: string): Promise<string> {
|
|
@@ -33,6 +33,8 @@ const CLAUDE_SHAPED = [
|
|
|
33
33
|
path.join('.claude', 'rules'),
|
|
34
34
|
path.join('.claude', 'skills'),
|
|
35
35
|
path.join('.claude', 'commands'),
|
|
36
|
+
// Injected into the prompt verbatim, and context-imports treats it as approval-gated.
|
|
37
|
+
path.join('.claude', 'CLAUDE.md'),
|
|
36
38
|
'CLAUDE.local.md',
|
|
37
39
|
'.mcp.json',
|
|
38
40
|
path.join('.pi', 'mcp.json'),
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface StdioServerConfig {
|
|
|
21
21
|
timeout?: number
|
|
22
22
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
23
23
|
aliasPrefix?: string
|
|
24
|
+
/** The server name as its manifest declares it, without the plugin: scope the registry
|
|
25
|
+
* key carries. pi-side tool names derive from this, so a plugin server contributes
|
|
26
|
+
* <server>_<tool> the way a user server does. */
|
|
27
|
+
baseName?: string
|
|
24
28
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
25
29
|
pluginRoot?: string
|
|
26
30
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
@@ -42,6 +46,10 @@ export interface HttpServerConfig {
|
|
|
42
46
|
timeout?: number
|
|
43
47
|
/** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
|
|
44
48
|
aliasPrefix?: string
|
|
49
|
+
/** The server name as its manifest declares it, without the plugin: scope the registry
|
|
50
|
+
* key carries. pi-side tool names derive from this, so a plugin server contributes
|
|
51
|
+
* <server>_<tool> the way a user server does. */
|
|
52
|
+
baseName?: string
|
|
45
53
|
/** Root of the plugin that supplied this server; exported as CLAUDE_PLUGIN_ROOT. */
|
|
46
54
|
pluginRoot?: string
|
|
47
55
|
/** Loaded from the project scope, whose helpers run credential-stripped. */
|
|
@@ -213,7 +221,11 @@ export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: strin
|
|
|
213
221
|
for (const plugin of plugins) {
|
|
214
222
|
for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
|
|
215
223
|
const substituted = substitutedPluginServer(plugin, name, config, projectDir)
|
|
216
|
-
|
|
224
|
+
// Claude: "The server itself registers under the scoped name
|
|
225
|
+
// plugin:<plugin-name>:<server-name>", which is what an mcp_tool hook names and what
|
|
226
|
+
// keeps a same-named user server from replacing a plugin's. The tool alias keeps its
|
|
227
|
+
// own flat spelling, mcp__plugin_<plugin>_<server>__<tool>.
|
|
228
|
+
if (substituted) servers[`plugin:${plugin.name}:${name}`] = { ...substituted, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__`, baseName: name, pluginRoot: plugin.root }
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
return servers
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -131,7 +131,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
131
131
|
function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
|
|
132
132
|
let count = 0
|
|
133
133
|
for (const tool of tools) {
|
|
134
|
-
|
|
134
|
+
// A plugin server's registry key carries its plugin: scope; its tools keep the bare
|
|
135
|
+
// server name, as their Claude alias does.
|
|
136
|
+
const toolName = formatToolName(config.baseName ?? name, tool.name)
|
|
135
137
|
const owner = registered.get(toolName)
|
|
136
138
|
if (owner === name) continue // already registered for this server: a refresh re-listing it
|
|
137
139
|
if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
|
|
@@ -28,6 +28,12 @@ function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError
|
|
|
28
28
|
* (stored-token) connects do not pass through here and stay fully parallel. */
|
|
29
29
|
let oauthQueue: Promise<unknown> = Promise.resolve()
|
|
30
30
|
|
|
31
|
+
/** Test seam: the queue is module state that outlives a test, so one login left pending
|
|
32
|
+
* would serialize every later login in the same file behind it. */
|
|
33
|
+
export function resetOAuthQueue(): void {
|
|
34
|
+
oauthQueue = Promise.resolve()
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
|
|
32
38
|
const result = oauthQueue.then(run, run)
|
|
33
39
|
oauthQueue = result.then(
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -97,11 +97,19 @@ function parsePolicyEntry(entry: unknown): McpPolicyEntry | undefined {
|
|
|
97
97
|
* honored scope sets the key; an empty list is an explicit lockdown (deny all). */
|
|
98
98
|
export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = managedSettingsFile()): McpPolicy {
|
|
99
99
|
const read = (file: string): Record<string, unknown> => {
|
|
100
|
+
let raw: string
|
|
100
101
|
try {
|
|
101
|
-
|
|
102
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
103
|
+
} catch {
|
|
104
|
+
return {} // no such file: genuinely no policy
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(raw)
|
|
102
108
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
103
109
|
} catch {
|
|
104
|
-
//
|
|
110
|
+
// Present but unreadable: its allow and deny lists are not in force, and a deny list
|
|
111
|
+
// silently dropped leaves every server allowed.
|
|
112
|
+
console.warn(`pi-code-mcp: ignoring ${file}: not valid JSON; its MCP allow and deny lists are not applied`)
|
|
105
113
|
return {}
|
|
106
114
|
}
|
|
107
115
|
}
|
|
@@ -284,12 +284,33 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** Transport-level failure codes worth another attempt. */
|
|
288
|
+
const TRANSIENT_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'])
|
|
289
|
+
|
|
290
|
+
/** Every `code` reachable from an error: its own, those of its `cause` chain, and those
|
|
291
|
+
* of an AggregateError's members. fetch reports a refused connection as a bare
|
|
292
|
+
* `TypeError: fetch failed` whose cause carries ECONNREFUSED, so the text says nothing. */
|
|
293
|
+
function errorCodes(error: unknown, seen = new Set<unknown>()): string[] {
|
|
294
|
+
if (error === null || typeof error !== 'object' || seen.has(error)) return []
|
|
295
|
+
seen.add(error)
|
|
296
|
+
const codes: string[] = []
|
|
297
|
+
const code = (error as { code?: unknown }).code
|
|
298
|
+
if (typeof code === 'string') codes.push(code)
|
|
299
|
+
const inner = (error as { errors?: unknown }).errors
|
|
300
|
+
if (Array.isArray(inner)) for (const one of inner) codes.push(...errorCodes(one, seen))
|
|
301
|
+
codes.push(...errorCodes((error as { cause?: unknown }).cause, seen))
|
|
302
|
+
return codes
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
/** Whether a connect failure is worth retrying: a 5xx response, a refused or reset
|
|
288
|
-
* connection, or a timeout
|
|
289
|
-
|
|
306
|
+
* connection, or a timeout, per Claude's "a 5xx response, a connection refused, or a
|
|
307
|
+
* timeout ... retries up to three times". Auth and not-found errors need a configuration
|
|
308
|
+
* change instead. Exported for the test that pins the shape node actually throws. */
|
|
309
|
+
export function isTransientConnectError(error: unknown): boolean {
|
|
290
310
|
if (isUnauthorized(error)) return false
|
|
291
|
-
const
|
|
292
|
-
if (typeof
|
|
311
|
+
const status = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
|
|
312
|
+
if (typeof status === 'number') return status >= 500
|
|
313
|
+
if (errorCodes(error).some((code) => TRANSIENT_CODES.has(code))) return true
|
|
293
314
|
return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
|
|
294
315
|
}
|
|
295
316
|
|
|
@@ -315,9 +336,11 @@ export async function connectWithRetries(name: string, config: ServerConfig, aut
|
|
|
315
336
|
* environment helperEnv built. It runs through the platform shell (/bin/sh; Git Bash
|
|
316
337
|
* or PowerShell on Windows). A failure, a 10s timeout, or a machine with no shell
|
|
317
338
|
* yields no extra headers rather than blocking the connection. */
|
|
318
|
-
function runHeadersHelper(command: string, env: NodeJS.ProcessEnv): Promise<Record<string, string>> {
|
|
339
|
+
export function runHeadersHelper(command: string, env: NodeJS.ProcessEnv, resolve_ = resolveShell): Promise<Record<string, string>> {
|
|
319
340
|
return new Promise((resolve) => {
|
|
320
|
-
|
|
341
|
+
// The resolver is a parameter so a test can drive both outcomes on any platform:
|
|
342
|
+
// stubbing the platform cannot produce a shell-less host on a machine that has one.
|
|
343
|
+
const shell = resolve_(undefined)
|
|
321
344
|
if (!shell) {
|
|
322
345
|
resolve({})
|
|
323
346
|
return
|
package/extensions/memory.ts
CHANGED
|
@@ -80,6 +80,9 @@ export function resolveMemoryDir(cwd: string, override?: string): string {
|
|
|
80
80
|
export function autoMemoryEnabled(setting: unknown, env: NodeJS.ProcessEnv): boolean {
|
|
81
81
|
const disable = (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY ?? '').trim().toLowerCase()
|
|
82
82
|
if (disable === '1' || disable === 'true') return false
|
|
83
|
+
// Claude: "Set to 0 to force auto memory on even when --bare mode or
|
|
84
|
+
// autoMemoryEnabled: false would otherwise disable it."
|
|
85
|
+
if (disable === '0' || disable === 'false') return true
|
|
83
86
|
return setting !== false
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -536,7 +539,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
536
539
|
` Auto memory: ${isEnabled ? 'on' : 'off'}`,
|
|
537
540
|
` Store: ${store}`,
|
|
538
541
|
` Index: ${path.join(store, INDEX_FILE)}`,
|
|
539
|
-
|
|
542
|
+
// The loader reads it from the configured directory, so CLAUDE_CONFIG_DIR moves it.
|
|
543
|
+
` User memory (CLAUDE.md): ${path.join(claudeConfigDir(home), 'CLAUDE.md')}`,
|
|
540
544
|
` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
|
|
541
545
|
// Claude's /memory lists every documented location, including files that
|
|
542
546
|
// do not exist yet.
|
|
@@ -31,8 +31,8 @@ import * as fs from 'node:fs'
|
|
|
31
31
|
import * as os from 'node:os'
|
|
32
32
|
import * as path from 'node:path'
|
|
33
33
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
34
|
-
|
|
35
34
|
import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
|
|
35
|
+
import { claudeEffortLevel } from './internal/effort.js'
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
38
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
@@ -296,10 +296,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
296
296
|
const sessionName = ctx.sessionManager.getSessionName?.()
|
|
297
297
|
if (sessionName) payload.session_name = sessionName
|
|
298
298
|
if (ctx.thinkingLevel) {
|
|
299
|
-
//
|
|
300
|
-
// low, and off omits effort entirely (thinking disabled says the rest).
|
|
299
|
+
// Thinking disabled says the rest, so off carries no effort field of its own.
|
|
301
300
|
payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
|
|
302
|
-
|
|
301
|
+
const effort = claudeEffortLevel(ctx.thinkingLevel)
|
|
302
|
+
if (effort) payload.effort = { level: effort }
|
|
303
303
|
}
|
|
304
304
|
if (styleName) payload.output_style = { name: styleName }
|
|
305
305
|
// The current utilization of the account's rate-limit windows, when the
|
|
@@ -764,7 +764,7 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
|
|
|
764
764
|
/** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
|
|
765
765
|
* does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
|
|
766
766
|
* extensions register in a child. Claude's capitalized spellings fold onto these. */
|
|
767
|
-
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource'])
|
|
767
|
+
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource', 'todo', 'question', 'memory', 'slash_command', 'plan_mode_complete'])
|
|
768
768
|
|
|
769
769
|
/** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
|
|
770
770
|
* to launch with an error naming the entries, instead of running tool-less. */
|
|
@@ -785,7 +785,12 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
785
785
|
// order (invocation model, frontmatter model, this variable, the session model).
|
|
786
786
|
// pi reads a thinking level from the model pattern's :suffix when a model is
|
|
787
787
|
// pinned, and from --thinking otherwise.
|
|
788
|
-
|
|
788
|
+
// Claude exempts the two built-ins from the environment variable: "Setting
|
|
789
|
+
// CLAUDE_CODE_SUBAGENT_MODEL by itself doesn't change the model the built-in Explore and
|
|
790
|
+
// Plan subagents run on." A model they name themselves, or one the invocation names,
|
|
791
|
+
// still applies.
|
|
792
|
+
const exemptFromEnvModel = agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')
|
|
793
|
+
const model = agent.model ?? aliasModel ?? (exemptFromEnvModel ? undefined : process.env.CLAUDE_CODE_SUBAGENT_MODEL)
|
|
789
794
|
if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
|
|
790
795
|
else if (agent.effort) args.push('--thinking', agent.effort)
|
|
791
796
|
// Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.54",
|
|
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",
|