pi-code 1.0.4 → 1.0.6
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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -6,12 +6,30 @@
|
|
|
6
6
|
* is what makes the rest of Claude's command contract reachable: namespaced
|
|
7
7
|
* subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
|
|
8
8
|
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
|
|
9
|
-
* `allowed-tools
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
10
|
+
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
11
|
+
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
12
|
+
* pwsh binary is installed, falling back to /bin/sh so the command still works
|
|
13
|
+
* without one.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
+
* Commands are also exposed to the model through a `slash_command` tool
|
|
16
|
+
* (Claude's SlashCommand tool), listing every discovered command whose file
|
|
17
|
+
* does not set `disable-model-invocation: true`. Only the command files this
|
|
18
|
+
* extension discovers are listed: pi built-ins and pi-code's own UI commands
|
|
19
|
+
* are user surfaces, not model surfaces. The model path is expansion only: the
|
|
20
|
+
* expanded body comes back as the tool result (sendUserMessage would spawn a
|
|
21
|
+
* second turn), `allowed-tools`/`disallowed-tools`/`model:` are not applied
|
|
22
|
+
* (applying them from inside a tool call would narrow the running batch and
|
|
23
|
+
* scope unrelated parallel tool calls, and the agent_end restore would lift
|
|
24
|
+
* the grant before the next model step could rely on it), and `!` spans are
|
|
25
|
+
* never executed on the model's demand: pi has no permission engine to gate
|
|
26
|
+
* repo-authored shell, so each span is replaced with Claude's
|
|
27
|
+
* "[shell command execution disabled by policy]" placeholder. The same
|
|
28
|
+
* placeholder is applied on every path when the `disableSkillShellExecution`
|
|
29
|
+
* settings key is set (user settings always, project settings when trusted,
|
|
30
|
+
* managed-settings.json as policy).
|
|
31
|
+
*
|
|
32
|
+
* A project command body is repository-controlled text that can run shell
|
|
15
33
|
* commands and read files, so project commands load only once the project is
|
|
16
34
|
* approved. That closes the "skills / commands are not trust-gated" limitation
|
|
17
35
|
* for commands; skills remain pi-loader territory.
|
|
@@ -23,12 +41,66 @@ import * as fs from 'node:fs'
|
|
|
23
41
|
import * as os from 'node:os'
|
|
24
42
|
import * as path from 'node:path'
|
|
25
43
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
44
|
+
import { Type } from 'typebox'
|
|
26
45
|
|
|
27
|
-
import {
|
|
46
|
+
import { matchesBashRules } from './internal/bash-rules.js'
|
|
47
|
+
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
49
|
+
import { matchesPathRules } from './internal/path-rules.js'
|
|
50
|
+
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
28
51
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
52
|
+
import { findNearestDir, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
53
|
+
|
|
54
|
+
type PathRuleTool = 'read' | 'edit' | 'write'
|
|
55
|
+
|
|
56
|
+
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
57
|
+
interface ModelLike {
|
|
58
|
+
id: string
|
|
59
|
+
name?: string
|
|
60
|
+
contextWindow?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve a command's `model:` frontmatter to an available model. Claude accepts a
|
|
64
|
+
* tier alias (sonnet/opus/haiku/fable), a concrete id, or `inherit`; pi matches by
|
|
65
|
+
* exact id first, then a substring of the id or name (the same fuzzy rule the
|
|
66
|
+
* subagent uses). `inherit` and an unresolvable name leave the model unchanged. */
|
|
67
|
+
function resolveCommandModel(model: string | undefined, available: ReadonlyArray<ModelLike>): ModelLike | undefined {
|
|
68
|
+
if (!model || model.toLowerCase() === 'inherit') return undefined
|
|
69
|
+
const needle = model.toLowerCase()
|
|
70
|
+
return available.find((m) => m.id.toLowerCase() === needle) ?? available.find((m) => m.id.toLowerCase().includes(needle) || (m.name ?? '').toLowerCase().includes(needle))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Substitute ${CLAUDE_*} into a path rule. A variable that expands to an absolute
|
|
74
|
+
* path at the anchor position (e.g. Read(${CLAUDE_PROJECT_DIR}/docs/**)) names that
|
|
75
|
+
* exact location, so it is marked with Claude's `//` filesystem-absolute anchor;
|
|
76
|
+
* otherwise resolveRule reads the single leading slash as project-relative and
|
|
77
|
+
* re-anchors it under the project root, where it can never match. */
|
|
78
|
+
function substitutePathRule(rule: string, vars: Record<string, string | undefined>): string {
|
|
79
|
+
const substituted = substituteVars(rule, vars)
|
|
80
|
+
return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
|
|
81
|
+
}
|
|
29
82
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
83
|
+
/** The path rules that survive an allowed-tools intersection: only the tools the
|
|
84
|
+
* grant kept get their scopes, each rule ${CLAUDE_*}-substituted like the body. */
|
|
85
|
+
function scopedPathRules(pathRules: Partial<Record<PathRuleTool, string[]>>, granted: string[], vars: Record<string, string | undefined>): Partial<Record<PathRuleTool, string[]>> {
|
|
86
|
+
const result: Partial<Record<PathRuleTool, string[]>> = {}
|
|
87
|
+
for (const [tool, rules] of Object.entries(pathRules)) {
|
|
88
|
+
if (granted.includes(tool)) result[tool as PathRuleTool] = rules.map((rule) => substitutePathRule(rule, vars))
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Wall-clock budget for one injected span: the Bash tool's documented 2-minute
|
|
94
|
+
* default, which is what Claude runs these commands under. */
|
|
95
|
+
const BASH_TIMEOUT_MS = 120_000
|
|
96
|
+
|
|
97
|
+
/** Context fields pi provides that Claude's ${CLAUDE_*} variables read from. */
|
|
98
|
+
interface VarContext {
|
|
99
|
+
sessionManager?: { getSessionId?: () => string }
|
|
100
|
+
thinkingLevel?: string
|
|
101
|
+
model?: ModelLike
|
|
102
|
+
modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
|
|
103
|
+
}
|
|
32
104
|
|
|
33
105
|
function isDirectory(target: string): boolean {
|
|
34
106
|
try {
|
|
@@ -39,10 +111,11 @@ function isDirectory(target: string): boolean {
|
|
|
39
111
|
}
|
|
40
112
|
|
|
41
113
|
/** Existing `.claude/commands` directories, user first then project. The project
|
|
42
|
-
* directory is
|
|
114
|
+
* directory is the nearest at or above cwd (bounded at the repository root, matching
|
|
115
|
+
* the approval walk) and is included only for approved projects. */
|
|
43
116
|
export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
44
117
|
const candidates = [path.join(home, '.claude', 'commands')]
|
|
45
|
-
if (trusted) candidates.push(path.join(cwd, '.claude', 'commands'))
|
|
118
|
+
if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'commands')) ?? path.join(cwd, '.claude', 'commands'))
|
|
46
119
|
const dirs: string[] = []
|
|
47
120
|
for (const dir of candidates) {
|
|
48
121
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -59,59 +132,325 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
|
|
|
59
132
|
return [...byName.values()]
|
|
60
133
|
}
|
|
61
134
|
|
|
135
|
+
/** A plugin's command files, namespaced `plugin:name` as Claude registers them.
|
|
136
|
+
* The manifest may point `commands` somewhere else; the default is `commands/`. */
|
|
137
|
+
export function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
|
|
138
|
+
const found: DiscoveredCommand[] = []
|
|
139
|
+
for (const plugin of plugins) {
|
|
140
|
+
const declared = plugin.manifest.commands
|
|
141
|
+
const dirs = (Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'commands']).map((entry) => path.resolve(plugin.root, String(entry)))
|
|
142
|
+
for (const dir of dirs) {
|
|
143
|
+
for (const command of discoverCommandFiles(dir)) {
|
|
144
|
+
found.push({ name: `${plugin.name}:${command.name}`, filePath: command.filePath, plugin: { root: plugin.root, dataDir: plugin.dataDir, ...(plugin.userConfig ? { userConfig: plugin.userConfig } : {}) } })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return found
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Claude's replacement text for a `!` span it refuses to execute. */
|
|
152
|
+
export const SHELL_DISABLED_PLACEHOLDER = '[shell command execution disabled by policy]'
|
|
153
|
+
|
|
154
|
+
type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether `disableSkillShellExecution` is set: the Claude settings key that
|
|
158
|
+
* replaces every `` !`cmd` `` and ```` ```! ```` span in skills and custom
|
|
159
|
+
* commands with SHELL_DISABLED_PLACEHOLDER instead of executing it. Read from
|
|
160
|
+
* user settings always, the project's settings.json/settings.local.json only
|
|
161
|
+
* when the project is trusted, and managed-settings.json as policy. Any layer
|
|
162
|
+
* setting it true wins: a repository's `false` must not lift the user's or the
|
|
163
|
+
* organization's policy, so this fails closed rather than last-file-wins.
|
|
164
|
+
*/
|
|
165
|
+
export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
|
|
166
|
+
if (readManagedSettings().disableSkillShellExecution === true) return true
|
|
167
|
+
const files = [path.join(home, '.claude', 'settings.json')]
|
|
168
|
+
if (trusted) {
|
|
169
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
170
|
+
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return files.some((file) => {
|
|
174
|
+
try {
|
|
175
|
+
return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
|
|
176
|
+
} catch {
|
|
177
|
+
return false // missing or invalid file: not a policy statement
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The ${CLAUDE_*} substitution sources for one command invocation. */
|
|
183
|
+
function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin): Record<string, string | undefined> {
|
|
184
|
+
const varCtx = ctx as unknown as VarContext
|
|
185
|
+
return {
|
|
186
|
+
CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
|
|
187
|
+
CLAUDE_EFFORT: varCtx.thinkingLevel,
|
|
188
|
+
CLAUDE_SKILL_DIR: path.dirname(filePath),
|
|
189
|
+
CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
|
|
190
|
+
CLAUDE_PLUGIN_ROOT: plugin?.root,
|
|
191
|
+
CLAUDE_PLUGIN_DATA: plugin?.dataDir,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The exec seam expandCommand runs spans through; pi itself satisfies it. */
|
|
196
|
+
interface SpanRunner {
|
|
197
|
+
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A command body expanded to the text a turn (or a tool result) carries:
|
|
202
|
+
* argument and named substitution, ${CLAUDE_*} and plugin variables, `!` spans
|
|
203
|
+
* and `@file` inlining, plus Claude's `ARGUMENTS:` append when the body never
|
|
204
|
+
* read what was passed. Throws when an injected span fails, so a caller never
|
|
205
|
+
* sees a half-expanded body. With `allowShell: false` no span executes at all;
|
|
206
|
+
* each is replaced by SHELL_DISABLED_PLACEHOLDER, which is how both the model
|
|
207
|
+
* path and the disableSkillShellExecution setting keep repo-authored shell
|
|
208
|
+
* from running.
|
|
209
|
+
*/
|
|
210
|
+
export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, args: string, ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin, options?: { allowShell?: boolean }): Promise<string> {
|
|
211
|
+
const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
212
|
+
const vars = commandVars(ctx, filePath, plugin)
|
|
213
|
+
const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
|
|
214
|
+
// `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
|
|
215
|
+
// ordinary command so a body that happens to contain the syntax is not stripped.
|
|
216
|
+
const substituted = substituteVars(withArgs, vars)
|
|
217
|
+
const withVars = plugin ? substituted.replace(/\$\{user_config\.(\w+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '') : substituted
|
|
218
|
+
|
|
219
|
+
const exec: CommandExec =
|
|
220
|
+
(options?.allowShell ?? true)
|
|
221
|
+
? async (script) => {
|
|
222
|
+
// Hooks get CLAUDE_PROJECT_DIR, and a command's shell span is the same kind of
|
|
223
|
+
// project-scoped script. pi.exec takes no env, so it is set in the script.
|
|
224
|
+
// stderr merges into stdout, as the Bash tool runs these for Claude. The
|
|
225
|
+
// resolver is passed by its imported binding so tests can stub the lookup.
|
|
226
|
+
const run = spanExec(parsed.shell, projectRoot, script, resolvePowershellBinary)
|
|
227
|
+
const result = await runner.exec(run.command, run.args, { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
|
|
228
|
+
// pwsh cannot merge a native command's stderr in-script (spanExec sets
|
|
229
|
+
// mergeStreams), so it is appended here; the sh script merges via 2>&1.
|
|
230
|
+
const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
|
|
231
|
+
return { stdout, stderr: result.stderr, code: result.code }
|
|
232
|
+
}
|
|
233
|
+
: async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
|
|
234
|
+
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec)
|
|
235
|
+
|
|
236
|
+
// Claude appends the raw arguments when the command never read them, so what
|
|
237
|
+
// the user typed still reaches the model.
|
|
238
|
+
if (args.trim().length > 0 && !consumed) expanded += `\n\nARGUMENTS: ${args.trim()}`
|
|
239
|
+
return expanded
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SlashCommandEntry {
|
|
243
|
+
name: string
|
|
244
|
+
description: string
|
|
245
|
+
argumentHint?: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One listed command's cap inside the tool description, as Claude cuts an
|
|
249
|
+
* oversized description rather than dropping the command. */
|
|
250
|
+
const ENTRY_CHAR_CAP = 1536
|
|
251
|
+
|
|
252
|
+
/** Claude's default character budget for the SlashCommand tool description. */
|
|
253
|
+
const DEFAULT_TOOL_CHAR_BUDGET = 15_000
|
|
254
|
+
|
|
255
|
+
/** The description budget: SLASH_COMMAND_TOOL_CHAR_BUDGET when set, else about
|
|
256
|
+
* 1% of the model's context window (1% of the tokens at ~4 characters per token
|
|
257
|
+
* is window / 25), else Claude's documented default. */
|
|
258
|
+
export function slashCommandBudget(contextWindow: number | undefined, env: Record<string, string | undefined> = process.env): number {
|
|
259
|
+
const override = Number.parseInt(env.SLASH_COMMAND_TOOL_CHAR_BUDGET ?? '', 10)
|
|
260
|
+
if (Number.isInteger(override) && override > 0) return override
|
|
261
|
+
if (contextWindow && contextWindow > 0) return Math.floor(contextWindow / 25)
|
|
262
|
+
return DEFAULT_TOOL_CHAR_BUDGET
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The slash_command tool description: usage framing plus the budgeted command
|
|
266
|
+
* list, each entry `/name - description (argument-hint)`. */
|
|
267
|
+
export function slashCommandToolDescription(commands: SlashCommandEntry[], budget: number): string {
|
|
268
|
+
const lines: string[] = []
|
|
269
|
+
let used = 0
|
|
270
|
+
let omitted = 0
|
|
271
|
+
for (const command of commands) {
|
|
272
|
+
const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
|
|
273
|
+
const entry = `/${command.name} - ${command.description}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
|
|
274
|
+
if (used + entry.length + 1 > budget) {
|
|
275
|
+
omitted++
|
|
276
|
+
continue // a shorter later entry may still fit the remaining budget
|
|
277
|
+
}
|
|
278
|
+
used += entry.length + 1
|
|
279
|
+
lines.push(entry)
|
|
280
|
+
}
|
|
281
|
+
if (omitted > 0) lines.push(`(${omitted} more ${omitted === 1 ? 'command was' : 'commands were'} omitted: raise SLASH_COMMAND_TOOL_CHAR_BUDGET to list them)`)
|
|
282
|
+
return ["Execute a custom slash command on the user's behalf. The command expands to instructions for you to follow in this conversation.", '', 'Available commands:', ...lines].join('\n')
|
|
283
|
+
}
|
|
284
|
+
|
|
62
285
|
export default function commandsExtension(pi: ExtensionAPI) {
|
|
63
286
|
const registered = new Set<string>()
|
|
64
|
-
/**
|
|
287
|
+
/** Every command file discovered for the current session, by name, for the
|
|
288
|
+
* slash_command tool to resolve against. Rebuilt on each session_start (a resume,
|
|
289
|
+
* fork, or new session can land on a different project) so the model never resolves
|
|
290
|
+
* a command left over from a previous project's session. Kept fresh even for names
|
|
291
|
+
* already registered. */
|
|
292
|
+
const discovered = new Map<string, DiscoveredCommand>()
|
|
293
|
+
/** pi has no unregister, so the slash_command tool registers once per process. */
|
|
294
|
+
let toolRegistered = false
|
|
295
|
+
/** The session_start approval decision, reused for per-invocation settings reads. */
|
|
296
|
+
let projectApproved = false
|
|
297
|
+
/** Tool set to put back once the run a restricted command drove has ended. */
|
|
65
298
|
let pendingRestore: string[] | undefined
|
|
299
|
+
/** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
|
|
300
|
+
let pendingBashRules: string[] | undefined
|
|
301
|
+
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
302
|
+
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
303
|
+
/** The session model to restore after a command's `model:` override drove its run. */
|
|
304
|
+
let pendingModelRestore: ModelLike | undefined
|
|
66
305
|
|
|
67
|
-
|
|
306
|
+
// Claude's contract is "the grant clears when you send your next message", and
|
|
307
|
+
// pi's turn_end fires after every assistant step: restoring there stripped a
|
|
308
|
+
// multi-step command's tool and model scoping as soon as its first tool batch
|
|
309
|
+
// came back. agent_end is not the end either: it fires once per agent loop, ahead
|
|
310
|
+
// of an automatic retry, an auto-compaction-and-retry, or a Stop-hook continuation,
|
|
311
|
+
// so restoring there lifts the scoping before that continued run executes.
|
|
312
|
+
// agent_settled fires exactly once, after the run has fully settled and no such
|
|
313
|
+
// continuation remains, which is the grant's true clearing point.
|
|
314
|
+
pi.on('agent_settled', async () => {
|
|
315
|
+
pendingBashRules = undefined
|
|
316
|
+
pendingPathRules = undefined
|
|
317
|
+
if (pendingModelRestore) {
|
|
318
|
+
void pi.setModel(pendingModelRestore as Parameters<typeof pi.setModel>[0])
|
|
319
|
+
pendingModelRestore = undefined
|
|
320
|
+
}
|
|
68
321
|
if (!pendingRestore) return
|
|
69
322
|
pi.setActiveTools(pendingRestore)
|
|
70
323
|
pendingRestore = undefined
|
|
71
324
|
})
|
|
72
325
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
326
|
+
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
327
|
+
// the whole tool; the scope is enforced here instead, when the call arrives. Same
|
|
328
|
+
// steering-not-sandbox caveat as plan mode's guard.
|
|
329
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
330
|
+
if (pendingBashRules && event.toolName === 'bash') {
|
|
331
|
+
const command = typeof event.input.command === 'string' ? event.input.command : ''
|
|
332
|
+
if (matchesBashRules(command, pendingBashRules)) return
|
|
333
|
+
return {
|
|
334
|
+
block: true,
|
|
335
|
+
reason: `allowed-tools: bash is scoped for this command.\nAllowed: ${pendingBashRules.join(', ')}\nCommand: ${command}`,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
339
|
+
if (!rules) return
|
|
340
|
+
const input = event.input as Record<string, unknown>
|
|
341
|
+
const filePath = typeof input.path === 'string' ? input.path : ''
|
|
342
|
+
const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
|
|
343
|
+
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
344
|
+
return {
|
|
345
|
+
block: true,
|
|
346
|
+
reason: `allowed-tools: ${event.toolName} is scoped for this command.\nAllowed: ${rules.join(', ')}\nPath: ${filePath}`,
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
/** Take the tool set to restore when this run ends, captured once per turn so a
|
|
351
|
+
* second restricted command narrows against the original unrestricted set. */
|
|
352
|
+
function captureRestorePoint(): string[] {
|
|
353
|
+
const original = pendingRestore ?? pi.getActiveTools()
|
|
354
|
+
pendingRestore = original
|
|
355
|
+
return original
|
|
356
|
+
}
|
|
83
357
|
|
|
84
|
-
|
|
85
|
-
|
|
358
|
+
/** Apply a command's allowed-tools to the run it drives: intersect with the tools
|
|
359
|
+
* pi actually has, keep the bash and path scopes for the tool_call guard, and let
|
|
360
|
+
* agent_settled restore the previous set. */
|
|
361
|
+
function applyAllowedTools(parsed: ParsedCommand, vars: Record<string, string | undefined>): void {
|
|
362
|
+
const allowed = parsed.allowedTools
|
|
363
|
+
if (!allowed) return
|
|
364
|
+
// Only the first restriction in a turn sees the unrestricted set; a second
|
|
365
|
+
// command must grant and restore against that original set, or its own tools
|
|
366
|
+
// are intersected away by the first command's narrowing.
|
|
367
|
+
const original = captureRestorePoint()
|
|
368
|
+
const granted = allowed.filter((tool) => original.includes(tool))
|
|
369
|
+
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
370
|
+
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
371
|
+
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
372
|
+
if (granted.length > 0 || allowed.length === 0) pi.setActiveTools(granted)
|
|
373
|
+
// The latest restricted command speaks for the turn: a later unscoped grant
|
|
374
|
+
// lifts an earlier command's scopes rather than stacking under them. Rules get
|
|
375
|
+
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
376
|
+
// script by its real path, as the skills docs show.
|
|
377
|
+
pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
|
|
378
|
+
pendingPathRules = parsed.pathRules ? scopedPathRules(parsed.pathRules, granted, vars) : undefined
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Claude removes disallowed-tools from the pool while the command is active; with
|
|
382
|
+
* both fields present the removal wins, as in subagent tool lists. */
|
|
383
|
+
function applyDisallowedTools(parsed: ParsedCommand): void {
|
|
384
|
+
const disallowed = parsed.disallowedTools
|
|
385
|
+
if (!disallowed || disallowed.length === 0) return
|
|
386
|
+
captureRestorePoint()
|
|
387
|
+
pi.setActiveTools(pi.getActiveTools().filter((tool) => !disallowed.includes(tool)))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Claude's `model:` frontmatter overrides the model for this run only, then the
|
|
391
|
+
* session model resumes; restore happens on agent_settled like the tool-set restore.
|
|
392
|
+
* Applied before sendUserMessage so the run it drives happens on the new model. */
|
|
393
|
+
async function applyModelOverride(parsed: ParsedCommand, varCtx: VarContext): Promise<void> {
|
|
394
|
+
const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
|
|
395
|
+
if (target && varCtx.model && target.id !== varCtx.model.id) {
|
|
396
|
+
pendingModelRestore = pendingModelRestore ?? varCtx.model
|
|
397
|
+
await pi.setModel(target as Parameters<typeof pi.setModel>[0])
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: CommandPlugin): Promise<void> {
|
|
402
|
+
const varCtx = ctx as unknown as VarContext
|
|
403
|
+
const vars = commandVars(ctx, filePath, plugin)
|
|
404
|
+
|
|
405
|
+
let expanded: string
|
|
406
|
+
try {
|
|
407
|
+
expanded = await expandCommand(pi, parsed, args, ctx, filePath, plugin, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), projectApproved) })
|
|
408
|
+
} catch (error) {
|
|
409
|
+
// A failed injected command aborts the invocation; the model never sees a
|
|
410
|
+
// half-expanded body. The notify carries Claude's failure message format.
|
|
411
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// allowed-tools restricts the run the command drives, and the previous set is
|
|
416
|
+
// restored when that run ends. Restoring inline does not work: sendUserMessage is
|
|
86
417
|
// fire-and-forget, so the restore would land before the agent ever read the tool
|
|
87
418
|
// list, leaving the command running with everything enabled.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// are intersected away by the first command's narrowing.
|
|
92
|
-
const original = pendingRestore ?? pi.getActiveTools()
|
|
93
|
-
pendingRestore = original
|
|
94
|
-
const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
|
|
95
|
-
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
96
|
-
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
97
|
-
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
98
|
-
if (granted.length > 0 || parsed.allowedTools.length === 0) pi.setActiveTools(granted)
|
|
99
|
-
}
|
|
419
|
+
applyAllowedTools(parsed, vars)
|
|
420
|
+
applyDisallowedTools(parsed)
|
|
421
|
+
await applyModelOverride(parsed, varCtx)
|
|
100
422
|
pi.sendUserMessage(expanded)
|
|
101
423
|
}
|
|
102
424
|
|
|
103
425
|
pi.on('session_start', async (_event, ctx) => {
|
|
104
426
|
const trusted = await isProjectApproved(ctx)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
427
|
+
projectApproved = trusted
|
|
428
|
+
// A resume/fork/new session can switch projects in-process. pi cannot unregister a
|
|
429
|
+
// command or re-describe the slash_command tool, so a name registered in an earlier
|
|
430
|
+
// project keeps its user-path binding and the tool description stays frozen at the
|
|
431
|
+
// first session's list; that is a pi limitation. What must not persist is the map
|
|
432
|
+
// the model resolves against, so it is rebuilt from scratch here: a stale command
|
|
433
|
+
// from a previous project resolves to "unknown" rather than being expanded.
|
|
434
|
+
discovered.clear()
|
|
435
|
+
// Plugins are user-installed and enabled by user settings; a checked-out repo
|
|
436
|
+
// must not silently flip which code-bearing plugins run, so enablement is
|
|
437
|
+
// user-scoped and never reads the project settings chain (see installedPlugins).
|
|
438
|
+
const plugins = pluginCommands(installedPlugins(os.homedir()))
|
|
439
|
+
const invocable: SlashCommandEntry[] = []
|
|
440
|
+
for (const command of [...collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted)), ...plugins]) {
|
|
109
441
|
let parsed: ParsedCommand
|
|
110
442
|
try {
|
|
111
443
|
parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
|
|
112
444
|
} catch {
|
|
113
445
|
continue // an unreadable command file must not take down session start
|
|
114
446
|
}
|
|
447
|
+
discovered.set(command.name, command)
|
|
448
|
+
// A user-only command stays off the tool description; it is still in the
|
|
449
|
+
// map so a model attempt gets the explicit refusal, not "unknown command".
|
|
450
|
+
if (!parsed.disableModelInvocation) invocable.push({ name: command.name, description: parsed.description, argumentHint: parsed.argumentHint })
|
|
451
|
+
// pi has no unregister, so a command already registered this process keeps its
|
|
452
|
+
// original file binding; re-registering would only add a numbered duplicate.
|
|
453
|
+
if (registered.has(command.name)) continue
|
|
115
454
|
registered.add(command.name)
|
|
116
455
|
pi.registerCommand(command.name, {
|
|
117
456
|
description: parsed.argumentHint ? `${parsed.description} ${parsed.argumentHint}` : parsed.description,
|
|
@@ -123,9 +462,46 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
123
462
|
} catch {
|
|
124
463
|
// fall back to what was parsed at registration
|
|
125
464
|
}
|
|
126
|
-
await runCommand(current, args, commandCtx)
|
|
465
|
+
await runCommand(current, args, commandCtx, command.filePath, command.plugin)
|
|
127
466
|
},
|
|
128
467
|
})
|
|
129
468
|
}
|
|
469
|
+
|
|
470
|
+
// Claude's SlashCommand tool, registered only when there is something for the
|
|
471
|
+
// model to call: an empty tool would spend context saying "nothing available".
|
|
472
|
+
if (toolRegistered || invocable.length === 0) return
|
|
473
|
+
toolRegistered = true
|
|
474
|
+
pi.registerTool({
|
|
475
|
+
name: 'slash_command',
|
|
476
|
+
label: 'SlashCommand',
|
|
477
|
+
description: slashCommandToolDescription(invocable, slashCommandBudget((ctx as unknown as VarContext).model?.contextWindow)),
|
|
478
|
+
parameters: Type.Object({ command: Type.String({ description: 'The command to run with args, e.g. "/deploy staging"' }) }),
|
|
479
|
+
async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
|
|
480
|
+
const line = params.command.trim().replace(/^\//, '')
|
|
481
|
+
const space = line.search(/\s/)
|
|
482
|
+
const name = space === -1 ? line : line.slice(0, space)
|
|
483
|
+
const args = space === -1 ? '' : line.slice(space + 1).trim()
|
|
484
|
+
// pi marks a tool result as an error only when execute() throws, so the
|
|
485
|
+
// failure paths below throw rather than return.
|
|
486
|
+
const command = discovered.get(name)
|
|
487
|
+
if (!name || !command) throw new Error(`Unknown command: /${name || params.command}. Only the custom commands listed in the slash_command tool description can be run.`)
|
|
488
|
+
// Live-edit parity with the user path: re-read on every invocation, so a
|
|
489
|
+
// just-added disable-model-invocation takes effect immediately too.
|
|
490
|
+
let current: ParsedCommand
|
|
491
|
+
try {
|
|
492
|
+
current = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
|
|
493
|
+
} catch {
|
|
494
|
+
throw new Error(`/${name}: the command file could not be read (${command.filePath}).`)
|
|
495
|
+
}
|
|
496
|
+
if (current.disableModelInvocation) {
|
|
497
|
+
throw new Error(`/${name} is user-only (disable-model-invocation: true) and was not run. Do not reproduce this command's steps or try to achieve its effect another way; only the user can invoke it.`)
|
|
498
|
+
}
|
|
499
|
+
// Expansion only, never sendUserMessage (that would spawn a second turn):
|
|
500
|
+
// the tool result is the channel, and frontmatter scoping stays user-path
|
|
501
|
+
// territory (see the header).
|
|
502
|
+
const expanded = await expandCommand(pi, current, args, execCtx, command.filePath, command.plugin, { allowShell: false })
|
|
503
|
+
return { content: [{ type: 'text' as const, text: `Contents of /${name} (expanded):\n\n${expanded}` }], details: {} }
|
|
504
|
+
},
|
|
505
|
+
})
|
|
130
506
|
})
|
|
131
507
|
}
|