pi-code 1.0.5 → 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.
@@ -7,12 +7,29 @@
7
7
  * subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
8
8
  * positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
9
9
  * `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
10
- * session model for the command's turn via `pi.setModel`, restored on turn_end).
10
+ * session model for the command's run via `pi.setModel`, restored on agent_end).
11
11
  * `shell: powershell` runs a command's injected spans through PowerShell when a
12
12
  * pwsh binary is installed, falling back to /bin/sh so the command still works
13
- * without one. `disable-model-invocation` is parsed but not applied yet.
13
+ * without one.
14
14
  *
15
- * 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
16
33
  * commands and read files, so project commands load only once the project is
17
34
  * approved. That closes the "skills / commands are not trust-gated" limitation
18
35
  * for commands; skills remain pi-loader territory.
@@ -24,13 +41,15 @@ import * as fs from 'node:fs'
24
41
  import * as os from 'node:os'
25
42
  import * as path from 'node:path'
26
43
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
44
+ import { Type } from 'typebox'
27
45
 
28
46
  import { matchesBashRules } from './internal/bash-rules.js'
29
- import { type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.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'
30
49
  import { matchesPathRules } from './internal/path-rules.js'
31
50
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
32
51
  import { isProjectApproved } from './internal/project-approval.js'
33
- import { findNearestDir, repoRoot } from './internal/project-root.js'
52
+ import { findNearestDir, findNearestFile, repoRoot } from './internal/project-root.js'
34
53
 
35
54
  type PathRuleTool = 'read' | 'edit' | 'write'
36
55
 
@@ -38,6 +57,7 @@ type PathRuleTool = 'read' | 'edit' | 'write'
38
57
  interface ModelLike {
39
58
  id: string
40
59
  name?: string
60
+ contextWindow?: number
41
61
  }
42
62
 
43
63
  /** Resolve a command's `model:` frontmatter to an available model. Claude accepts a
@@ -60,6 +80,16 @@ function substitutePathRule(rule: string, vars: Record<string, string | undefine
60
80
  return rule.trimStart().startsWith('${CLAUDE_') && path.isAbsolute(substituted) ? `/${substituted}` : substituted
61
81
  }
62
82
 
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
+
63
93
  /** Wall-clock budget for one injected span: the Bash tool's documented 2-minute
64
94
  * default, which is what Claude runs these commands under. */
65
95
  const BASH_TIMEOUT_MS = 120_000
@@ -118,18 +148,170 @@ export function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[]
118
148
  return found
119
149
  }
120
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
+
121
285
  export default function commandsExtension(pi: ExtensionAPI) {
122
286
  const registered = new Set<string>()
123
- /** 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. */
124
298
  let pendingRestore: string[] | undefined
125
- /** `Bash(...)` scopes enforced while that turn runs; lifted with the restriction. */
299
+ /** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
126
300
  let pendingBashRules: string[] | undefined
127
301
  /** Read/Edit path scopes enforced the same way, per pi file tool. */
128
302
  let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
129
- /** The session model to restore after a command's `model:` override drove its turn. */
303
+ /** The session model to restore after a command's `model:` override drove its run. */
130
304
  let pendingModelRestore: ModelLike | undefined
131
305
 
132
- 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 () => {
133
315
  pendingBashRules = undefined
134
316
  pendingPathRules = undefined
135
317
  if (pendingModelRestore) {
@@ -165,37 +347,64 @@ export default function commandsExtension(pi: ExtensionAPI) {
165
347
  }
166
348
  })
167
349
 
168
- async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext, filePath: string, plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }): Promise<void> {
169
- const varCtx = ctx as unknown as VarContext
170
- const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
171
- const vars: Record<string, string | undefined> = {
172
- CLAUDE_SESSION_ID: varCtx.sessionManager?.getSessionId?.(),
173
- CLAUDE_EFFORT: varCtx.thinkingLevel,
174
- CLAUDE_SKILL_DIR: path.dirname(filePath),
175
- CLAUDE_PROJECT_DIR: projectRoot,
176
- CLAUDE_PLUGIN_ROOT: plugin?.root,
177
- CLAUDE_PLUGIN_DATA: plugin?.dataDir,
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
+ }
357
+
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])
178
398
  }
179
- const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
180
- // `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
181
- // ordinary command so a body that happens to contain the syntax is not stripped.
182
- const substituted = substituteVars(withArgs, vars)
183
- const withVars = plugin ? substituted.replace(/\$\{user_config\.([A-Za-z0-9_]+)\}/g, (_, key: string) => plugin.userConfig?.[key] ?? '') : substituted
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)
184
404
 
185
405
  let expanded: string
186
406
  try {
187
- expanded = await expandDynamicContent(withVars, ctx.cwd, async (script) => {
188
- // Hooks get CLAUDE_PROJECT_DIR, and a command's shell span is the same kind of
189
- // project-scoped script. pi.exec takes no env, so it is set in the script.
190
- // stderr merges into stdout, as the Bash tool runs these for Claude. The
191
- // resolver is passed by its imported binding so tests can stub the lookup.
192
- const run = spanExec(parsed.shell, projectRoot, script, resolvePowershellBinary)
193
- const result = await pi.exec(run.command, run.args, { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
194
- // pwsh cannot merge a native command's stderr in-script (spanExec sets
195
- // mergeStreams), so it is appended here; the sh script merges via 2>&1.
196
- const stdout = run.mergeStreams ? result.stdout + result.stderr : result.stdout
197
- return { stdout, stderr: result.stderr, code: result.code }
198
- })
407
+ expanded = await expandCommand(pi, parsed, args, ctx, filePath, plugin, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), projectApproved) })
199
408
  } catch (error) {
200
409
  // A failed injected command aborts the invocation; the model never sees a
201
410
  // half-expanded body. The notify carries Claude's failure message format.
@@ -203,73 +412,45 @@ export default function commandsExtension(pi: ExtensionAPI) {
203
412
  return
204
413
  }
205
414
 
206
- // Claude appends the raw arguments when the command never read them, so what
207
- // the user typed still reaches the model.
208
- if (args.trim().length > 0 && !consumed) expanded += `\n\nARGUMENTS: ${args.trim()}`
209
-
210
- // allowed-tools restricts the turn the command drives, and the previous set is
211
- // restored when that turn ends. Restoring inline does not work: sendUserMessage is
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
212
417
  // fire-and-forget, so the restore would land before the agent ever read the tool
213
418
  // list, leaving the command running with everything enabled.
214
- if (parsed.allowedTools) {
215
- // Only the first restriction in a turn sees the unrestricted set; a second
216
- // command must grant and restore against that original set, or its own tools
217
- // are intersected away by the first command's narrowing.
218
- const original = pendingRestore ?? pi.getActiveTools()
219
- pendingRestore = original
220
- const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
221
- // `allowed-tools: []` says no tools, and is honored. A non-empty list that
222
- // intersects to nothing named only tools pi has none of: that restriction cannot
223
- // be expressed, and applying it as "no tools" is not what the command asked for.
224
- if (granted.length > 0 || parsed.allowedTools.length === 0) pi.setActiveTools(granted)
225
- // The latest restricted command speaks for the turn: a later unscoped grant
226
- // lifts an earlier command's scopes rather than stacking under them. Rules get
227
- // the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
228
- // script by its real path, as the skills docs show.
229
- pendingBashRules = granted.includes('bash') ? parsed.bashRules?.map((rule) => substituteVars(rule, vars)) : undefined
230
- pendingPathRules = undefined
231
- if (parsed.pathRules) {
232
- pendingPathRules = {}
233
- for (const [tool, rules] of Object.entries(parsed.pathRules)) {
234
- if (granted.includes(tool)) pendingPathRules[tool as PathRuleTool] = rules.map((rule) => substitutePathRule(rule, vars))
235
- }
236
- }
237
- }
238
- // Claude removes disallowed-tools from the pool while the skill is active;
239
- // with both fields present the removal wins, as in subagent tool lists.
240
- if (parsed.disallowedTools && parsed.disallowedTools.length > 0) {
241
- const original = pendingRestore ?? pi.getActiveTools()
242
- pendingRestore = original
243
- const disallowed = parsed.disallowedTools
244
- pi.setActiveTools(pi.getActiveTools().filter((tool) => !disallowed.includes(tool)))
245
- }
246
- // Claude's `model:` frontmatter overrides the model for this turn only, then the
247
- // session model resumes; restore happens on turn_end like the tool-set restore.
248
- // Applied before sendUserMessage so the turn it drives runs on the new model.
249
- const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
250
- if (target && varCtx.model && target.id !== varCtx.model.id) {
251
- pendingModelRestore = pendingModelRestore ?? varCtx.model
252
- await pi.setModel(target as Parameters<typeof pi.setModel>[0])
253
- }
419
+ applyAllowedTools(parsed, vars)
420
+ applyDisallowedTools(parsed)
421
+ await applyModelOverride(parsed, varCtx)
254
422
  pi.sendUserMessage(expanded)
255
423
  }
256
424
 
257
425
  pi.on('session_start', async (_event, ctx) => {
258
426
  const trusted = await isProjectApproved(ctx)
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()
259
435
  // Plugins are user-installed and enabled by user settings; a checked-out repo
260
436
  // must not silently flip which code-bearing plugins run, so enablement is
261
437
  // user-scoped and never reads the project settings chain (see installedPlugins).
262
438
  const plugins = pluginCommands(installedPlugins(os.homedir()))
439
+ const invocable: SlashCommandEntry[] = []
263
440
  for (const command of [...collectCommands(commandDirs(ctx.cwd, os.homedir(), trusted)), ...plugins]) {
264
- // pi has no unregister, so a command already registered this process keeps its
265
- // original file binding; re-registering would only add a numbered duplicate.
266
- if (registered.has(command.name)) continue
267
441
  let parsed: ParsedCommand
268
442
  try {
269
443
  parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
270
444
  } catch {
271
445
  continue // an unreadable command file must not take down session start
272
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
273
454
  registered.add(command.name)
274
455
  pi.registerCommand(command.name, {
275
456
  description: parsed.argumentHint ? `${parsed.description} ${parsed.argumentHint}` : parsed.description,
@@ -285,5 +466,42 @@ export default function commandsExtension(pi: ExtensionAPI) {
285
466
  },
286
467
  })
287
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
+ })
288
506
  })
289
507
  }