pi-code 1.0.37 → 1.0.39

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.
@@ -236,7 +236,7 @@ export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, a
236
236
  return { stdout, stderr: result.stderr, code: result.code }
237
237
  }
238
238
  : async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
239
- let expanded = await expandDynamicContent(withVars, ctx.cwd, exec)
239
+ let expanded = await expandDynamicContent(withVars, ctx.cwd, exec, parsed.shell === 'powershell' ? 'powershell' : 'bash')
240
240
 
241
241
  // Claude appends the raw arguments when the command never read them, so what
242
242
  // the user typed still reaches the model.
@@ -16,6 +16,12 @@ export interface AgentRunRequest {
16
16
  model?: string
17
17
  /** Optional extra system prompt (Claude's experimental `systemPrompt` field). */
18
18
  systemPrompt?: string
19
+ /** A discovered agent to run as (Claude's `agent` field on context: fork
20
+ * skills); unknown names fall back per fullTools. */
21
+ agent?: string
22
+ /** Run with the full toolset instead of the read-only hook shape, for
23
+ * context: fork skills. */
24
+ fullTools?: boolean
19
25
  /** Aborts the run at the hook's deadline. */
20
26
  signal?: AbortSignal
21
27
  }
@@ -49,7 +49,7 @@ export interface ParsedCommand {
49
49
  }
50
50
 
51
51
  export interface DiscoveredCommand {
52
- /** Claude's namespaced name: a nested file is `dir:name`, a plugin's is `plugin:name`. */
52
+ /** Claude names a command by its file name alone; a plugin's is `plugin:name`. */
53
53
  name: string
54
54
  filePath: string
55
55
  /** Set for plugin commands, carrying the ${CLAUDE_PLUGIN_*} and ${user_config.*} substitution sources. */
@@ -539,16 +539,21 @@ function fencedRanges(body: string): Array<[number, number]> {
539
539
  }
540
540
 
541
541
  /** Exit 1 is a normal result for Claude's documented search and comparison commands
542
- * (no matches, files differ); exit 2 and up fails even for these. */
542
+ * (no matches, files differ); exit 2 and up fails even for these. The PowerShell
543
+ * shell uses a different set, which "includes grep and git diff but not find or
544
+ * diff" (test/[ are bash builtins and do not apply there either). */
543
545
  const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
546
+ const EXIT_ONE_OK_POWERSHELL = new Set(['grep', 'rg', 'egrep', 'fgrep'])
544
547
 
545
- const isCarveoutSegment = (segment: string): boolean => {
548
+ export type SpanShell = 'bash' | 'powershell'
549
+
550
+ const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
546
551
  const words = segment.trim().split(/\s+/)
547
552
  if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
548
- return EXIT_ONE_OK.has(words[0])
553
+ return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
549
554
  }
550
555
 
551
- function benignExitOne(command: string): boolean {
556
+ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
552
557
  const segments = splitSegments(command)
553
558
  if (segments.length === 0) return false
554
559
  // A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
@@ -557,15 +562,15 @@ function benignExitOne(command: string): boolean {
557
562
  // the exit benign whichever ran last. Without short-circuit operators the exit is
558
563
  // the last segment's (a `|` pipeline exits with its final command, `;`/newline with
559
564
  // the last statement), so the last segment decides.
560
- if (/&&|\|\|/.test(command)) return segments.every(isCarveoutSegment)
561
- return isCarveoutSegment(segments.at(-1) ?? '')
565
+ if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
566
+ return isCarveoutSegment(segments.at(-1) ?? '', shell)
562
567
  }
563
568
 
564
569
  /** Run one injected span. A failure aborts the whole invocation, as Claude
565
570
  * documents: the model never sees a half-expanded body. */
566
- async function runSpan(exec: CommandExec, command: string, pattern: string): Promise<string> {
571
+ async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
567
572
  const result = await exec(command)
568
- if (result.code !== 0 && !(result.code === 1 && benignExitOne(command))) {
573
+ if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
569
574
  throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
570
575
  }
571
576
  return result.stdout.trimEnd()
@@ -608,7 +613,7 @@ interface DynamicSpan {
608
613
  * never re-scanned for further placeholders. Re-scanning was both a parity break
609
614
  * (Claude expands once) and a command-injection path: output of a `` ```! `` block
610
615
  * such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
611
- export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
616
+ export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec, shell: SpanShell = 'bash'): Promise<string> {
612
617
  const blocks = fenceBlocks(body)
613
618
  const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
614
619
  const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
@@ -618,14 +623,14 @@ export async function expandDynamicContent(body: string, cwd: string, exec: Comm
618
623
 
619
624
  const spans: DynamicSpan[] = []
620
625
  for (const block of blocks) {
621
- if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!') })
626
+ if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!', shell) })
622
627
  }
623
628
  // `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
624
629
  const bashPattern = /(^|\s)!`([^`]+)`/g
625
630
  for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
626
631
  if (literal(m.index)) continue
627
632
  const [span, lead, command] = m
628
- spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``)) })
633
+ spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``, shell)) })
629
634
  }
630
635
  const atPattern = /(^|\s)@(\S+)/g
631
636
  for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
@@ -16,6 +16,7 @@ import * as fs from 'node:fs'
16
16
  import * as path from 'node:path'
17
17
 
18
18
  import { claudeConfigDir } from './config-dir.js'
19
+ import { readManagedSettings } from './managed-settings.js'
19
20
 
20
21
  export interface InstalledPlugin {
21
22
  name: string
@@ -169,16 +170,30 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
169
170
  return plugins
170
171
  }
171
172
 
173
+ /** The plugin's effective enablement per Claude's precedence: a managed
174
+ * enabledPlugins entry force-enables or blocks, then the user's setting, then the
175
+ * manifest's defaultEnabled, which defaults to true ("starts in an enabled state
176
+ * when the user has not set one"). */
177
+ function pluginEnabled(qualified: string, pluginDir: string, enabled: Record<string, boolean>, manifest: Record<string, unknown>): boolean {
178
+ const managedEntry = readManagedSettings().enabledPlugins
179
+ if (managedEntry !== null && typeof managedEntry === 'object') {
180
+ const managedState = (managedEntry as Record<string, unknown>)[qualified] ?? (managedEntry as Record<string, unknown>)[pluginDir]
181
+ if (typeof managedState === 'boolean') return managedState
182
+ }
183
+ const userState = enabled[qualified] ?? enabled[pluginDir]
184
+ if (typeof userState === 'boolean') return userState
185
+ return manifest.defaultEnabled !== false
186
+ }
187
+
172
188
  /** Resolve one cached plugin directory into an enabled InstalledPlugin, or null to skip
173
- * it: not turned on in settings, or no version directory on disk yet. */
189
+ * it: turned off by managed/user settings or defaultEnabled, or no version yet. */
174
190
  function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>): InstalledPlugin | null {
175
191
  const qualified = `${pluginDir}@${marketplace}`
176
- const state = enabled[qualified] ?? enabled[pluginDir]
177
- if (state !== true) return null
178
192
  const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
179
193
  if (!version) return null
180
194
  const root = path.join(cacheDir, marketplace, pluginDir, version)
181
195
  const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
196
+ if (!pluginEnabled(qualified, pluginDir, enabled, manifest)) return null
182
197
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
183
198
  // Claude: "{id} is the plugin identifier with characters outside a-z, A-Z, 0-9,
184
199
  // _, and - replaced by -", one dash per character, underscores kept.
@@ -595,6 +595,34 @@ export default async function mcpExtension(pi: ExtensionAPI) {
595
595
  status.clear()
596
596
  })
597
597
 
598
+ // Claude references MCP resources with @server:uri mentions, fetched into the
599
+ // conversation when referenced. Only mentions naming a connected server expand;
600
+ // anything else (an email, a handle) stays untouched.
601
+ pi.on('input', async (event) => {
602
+ if (event.source === 'extension') return
603
+ const mentions = [...event.text.matchAll(/@([A-Za-z0-9_-]+):(\S+)/g)].filter((match) => clients.has(match[1]))
604
+ if (mentions.length === 0) return
605
+ const sections: string[] = []
606
+ for (const match of mentions) {
607
+ const [, server, uri] = match
608
+ try {
609
+ const client = clients.get(server)
610
+ if (!client) continue
611
+ const wall = callTimeoutMs()
612
+ const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall, callTuning(server))), wall, `read ${uri}`)
613
+ const text = (result.contents as Array<{ text?: string }>)
614
+ .map((entry) => entry.text)
615
+ .filter((value): value is string => typeof value === 'string')
616
+ .join('\n')
617
+ if (text) sections.push(capForContext(`<mcp-resource server="${server}" uri="${uri}">\n${text}\n</mcp-resource>`))
618
+ } catch {
619
+ // An unreadable resource leaves the mention as plain text.
620
+ }
621
+ }
622
+ if (sections.length === 0) return
623
+ return { action: 'transform', text: `${event.text}\n\n${sections.join('\n\n')}` }
624
+ })
625
+
598
626
  pi.registerCommand('mcp', {
599
627
  description: 'Show MCP server status and tools',
600
628
  handler: async (_args, ctx) => {
@@ -25,11 +25,14 @@ import * as path from 'node:path'
25
25
  import { type ExtensionAPI, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
26
26
 
27
27
  import { expandCommand, shellExecutionDisabled } from './commands.js'
28
+ import { runAgent } from './internal/agent-run.js'
28
29
  import { parseCommandFile } from './internal/command-file.js'
29
30
  import { claudeConfigDir } from './internal/config-dir.js'
31
+ import { managedSettingsFile } from './internal/managed-settings.js'
30
32
  import { installedPlugins } from './internal/plugins.js'
31
33
  import { isProjectApprovedSilently } from './internal/project-approval.js'
32
34
  import { ancestorDirs } from './internal/project-root.js'
35
+ import { claudeSettingsChain } from './internal/settings-chain.js'
33
36
  import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
34
37
 
35
38
  function isDirectory(target: string): boolean {
@@ -45,7 +48,10 @@ function isDirectory(target: string): boolean {
45
48
  * name and description to the model, so an untrusted repository would otherwise get
46
49
  * text into the prompt without the user ever agreeing to load its config. */
47
50
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
48
- const candidates = [path.join(claudeConfigDir(home), 'skills')]
51
+ // Claude's precedence: enterprise (the skills directory beside the managed
52
+ // settings file) overrides personal, and personal overrides project; discovery
53
+ // here is first-match, so higher precedence goes first.
54
+ const candidates = [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills'), path.join(claudeConfigDir(home), 'skills')]
49
55
  // Enabled plugins contribute their skills directories. pi's loader names a
50
56
  // skill by its directory, so a plugin skill registers without Claude's
51
57
  // /plugin: prefix; a rename-free approximation, disclosed in the README.
@@ -125,11 +131,49 @@ export default function skillsExtension(pi: ExtensionAPI) {
125
131
  })
126
132
  }
127
133
 
134
+ /** Claude's `skillOverrides` value for one skill from the settings chain, later
135
+ * files winning: "off" hides the skill entirely, "name-only" trims its listing
136
+ * (a pi-loader surface, noted in docs). */
137
+ function skillOverrideFor(name: string, cwd: string, trusted: boolean): string | undefined {
138
+ let value: string | undefined
139
+ for (const file of claudeSettingsChain(cwd, os.homedir(), trusted)) {
140
+ try {
141
+ const overrides = JSON.parse(fs.readFileSync(file, 'utf-8')).skillOverrides
142
+ if (overrides !== null && typeof overrides === 'object' && typeof overrides[name] === 'string') value = overrides[name]
143
+ } catch {
144
+ // missing or invalid file: skip
145
+ }
146
+ }
147
+ return value
148
+ }
149
+
150
+ /** Claude's skillOverrides "off": the skill is hidden and does not run; the
151
+ * invocation is swallowed with a notice. Undefined lets the invocation proceed. */
152
+ function refusedByOverride(name: string, ctx: ExtensionContext, trusted: boolean): { action: 'handled' } | undefined {
153
+ if (skillOverrideFor(name, ctx.cwd, trusted) !== 'off') return undefined
154
+ if (ctx.hasUI) ctx.ui.notify(`Skill "${name}" is turned off by skillOverrides in settings.`, 'info')
155
+ return { action: 'handled' }
156
+ }
157
+
158
+ /** Claude's context: fork run: the expanded skill content becomes the prompt that
159
+ * drives a subagent, without the conversation history. Divergence: Claude
160
+ * backgrounds the fork by default; pi-code waits for the result in the invoking
161
+ * turn (Claude's background: false behavior, which is also what Claude itself
162
+ * does in -p and SDK runs). */
163
+ async function runForkedSkill(name: string, filePath: string, expanded: string, agentName: string | undefined): Promise<{ action: 'transform'; text: string }> {
164
+ try {
165
+ const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
166
+ return { action: 'transform', text: `<skill name="${name}" location="${filePath}">\nThe skill ran in a forked subagent (no conversation history shared). Its result:\n\n${output}\n</skill>` }
167
+ } catch (error) {
168
+ return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${error instanceof Error ? error.message : String(error)}\n</skill>` }
169
+ }
170
+ }
171
+
128
172
  /** A `/skill:name args` invocation into its expanded skill block, or undefined to
129
173
  * pass the input through to pi untouched. The expanded body is wrapped in pi's
130
174
  * skill-block format so downstream behavior (the baseDir note for relative
131
175
  * references) matches an untouched invocation. */
132
- async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: ExtensionContext): Promise<{ action: 'transform'; text: string } | undefined> {
176
+ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: ExtensionContext): Promise<{ action: 'transform'; text: string } | { action: 'handled' } | undefined> {
133
177
  const text = rawText.trimStart()
134
178
  if (!text.startsWith('/skill:')) return
135
179
  const space = text.indexOf(' ')
@@ -139,6 +183,8 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
139
183
  const trusted = isProjectApprovedSilently(ctx)
140
184
  const found = findClaudeSkill(name, skillDirs(ctx.cwd, os.homedir(), trusted))
141
185
  if (!found) return
186
+ const refused = refusedByOverride(name, ctx, trusted)
187
+ if (refused) return refused
142
188
  let parsed: ReturnType<typeof parseCommandFile>
143
189
  let content: string
144
190
  try {
@@ -153,10 +199,14 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
153
199
  // Claude registers hooks a skill's frontmatter declares when the skill is
154
200
  // invoked, for the rest of the session; the hooks extension owns running them,
155
201
  // so the declaration is announced over the shared bus.
156
- const declaredHooks = parseFrontmatter<Record<string, unknown>>(content).frontmatter.hooks
202
+ const frontmatter = parseFrontmatter<Record<string, unknown>>(content).frontmatter
203
+ const declaredHooks = frontmatter.hooks
157
204
  if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
158
205
  pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
159
206
  }
160
207
  const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
208
+ if (typeof frontmatter.context === 'string' && frontmatter.context.trim().toLowerCase() === 'fork') {
209
+ return runForkedSkill(name, found.filePath, expanded, typeof frontmatter.agent === 'string' ? frontmatter.agent.trim() : undefined)
210
+ }
161
211
  return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
162
212
  }
@@ -650,6 +650,20 @@ export const AGENT_HOOK_SYSTEM = [
650
650
 
651
651
  /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
652
652
  * hook's model (a fast default when unset), and the decision-returning system prompt. */
653
+ /** The agent a context: fork skill runs as when it names none: full toolset, no
654
+ * extra system prompt (the child keeps pi's default), the skill content as the
655
+ * task. */
656
+ function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
657
+ return {
658
+ name: 'fork',
659
+ description: 'forked skill run',
660
+ systemPrompt: request.systemPrompt ?? '',
661
+ ...(request.model ? { model: request.model } : {}),
662
+ source: 'builtin',
663
+ filePath: '',
664
+ }
665
+ }
666
+
653
667
  export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
654
668
  return {
655
669
  name: 'agent-hook',
@@ -1467,7 +1481,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
1467
1481
  // A subagent session must not spawn further agents; agent hooks inside one are
1468
1482
  // skipped (the seam rejection is non-blocking in runAgentHook).
1469
1483
  if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
1470
- const agent = buildHookAgent(request)
1484
+ // A context: fork skill names its agent, or runs with the full toolset;
1485
+ // agent hooks keep the read-only hook shape.
1486
+ const named = request.agent ? discoverAgents(hookCwd, 'both').agents.find((a) => a.name === request.agent) : undefined
1487
+ const agent = named ?? (request.fullTools ? forkAgent(request) : buildHookAgent(request))
1471
1488
  const result = await runSingleAgent({
1472
1489
  defaultCwd: hookCwd,
1473
1490
  agents: [agent],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",