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.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -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` and `argument-hint` frontmatter. `model` and
10
- * `disable-model-invocation` are parsed but not applied yet: pi has seams for both
11
- * (`pi.setModel`, and commands are user-invoked anyway), so they are a gap rather
12
- * than an impossibility.
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
- * A project command body is repository-controlled text that can now run shell
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 { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, substituteArgs } from './internal/command-file.js'
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
- /** Wall-clock budget for one `` !`cmd` `` span; a hung command must not wedge a turn. */
31
- const BASH_TIMEOUT_MS = 30_000
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 included only for approved projects. */
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
- /** Tool set to put back once the turn a restricted command drove has ended. */
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
- pi.on('turn_end', async () => {
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
- async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
74
- const withArgs = substituteArgs(parsed.body, args)
75
- const expanded = await expandDynamicContent(withArgs, ctx.cwd, async (shell) => {
76
- // Hooks get CLAUDE_PROJECT_DIR, and a command's bash span is the same kind of
77
- // project-scoped script. pi.exec takes no env, so it is exported in the script.
78
- const projectDir = ctx.cwd.replaceAll("'", String.raw`'\''`)
79
- const script = `export CLAUDE_PROJECT_DIR='${projectDir}'; ${shell}`
80
- const result = await pi.exec('/bin/sh', ['-c', script], { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
81
- return { stdout: result.stdout, stderr: result.stderr, code: result.code }
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
- // allowed-tools restricts the turn the command drives, and the previous set is
85
- // restored when that turn ends. Restoring inline does not work: sendUserMessage is
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
- if (parsed.allowedTools) {
89
- // Only the first restriction in a turn sees the unrestricted set; a second
90
- // command must grant and restore against that original set, or its own tools
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
- for (const command of collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted))) {
106
- // pi has no unregister, so a command already registered this process keeps its
107
- // original file binding; re-registering would only add a numbered duplicate.
108
- if (registered.has(command.name)) continue
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
  }