pi-code 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/extensions/commands.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* handing `.claude/commands` to pi's prompt-template loader. Owning registration
|
|
6
6
|
* is what makes the rest of Claude's command contract reachable: `$ARGUMENTS` and
|
|
7
7
|
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, subdirectory
|
|
8
|
-
* commands (
|
|
9
|
-
*
|
|
8
|
+
* commands (named by file name alone, as Claude documents; subdirectories only
|
|
9
|
+
* organize the files), and the
|
|
10
10
|
* `allowed-tools`, `argument-hint` and `model` frontmatter (`model` switches the
|
|
11
11
|
* session model for the command's run via `pi.setModel`, restored on agent_end).
|
|
12
12
|
* `shell: powershell` runs a command's injected spans through PowerShell when a
|
|
@@ -47,7 +47,7 @@ import { Type } from 'typebox'
|
|
|
47
47
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
48
48
|
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
49
49
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
50
|
-
import { readManagedSettings } from './internal/managed-settings.js'
|
|
50
|
+
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
51
51
|
import { capForContext } from './internal/output-guard.js'
|
|
52
52
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
53
53
|
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
@@ -115,15 +115,17 @@ function isDirectory(target: string): boolean {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** Existing `.claude/commands` directories
|
|
119
|
-
*
|
|
120
|
-
*
|
|
118
|
+
/** Existing `.claude/commands` directories in Claude's precedence order (later
|
|
119
|
+
* directories win in collectCommands): project first, then personal, then the
|
|
120
|
+
* enterprise directory beside the managed settings file, per "enterprise
|
|
121
|
+
* overrides personal, and personal overrides project". The project directories
|
|
122
|
+
* are included only for approved projects. */
|
|
121
123
|
export function commandDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
122
|
-
const candidates = [
|
|
124
|
+
const candidates: string[] = []
|
|
123
125
|
// Claude scans every .claude/commands between cwd and the repository root, the
|
|
124
|
-
// nearest winning
|
|
125
|
-
// the project list goes root-first with the nearest last.
|
|
126
|
+
// nearest winning an intra-project name clash: root-first with the nearest last.
|
|
126
127
|
if (trusted) candidates.push(...ancestorDirs(cwd, path.join('.claude', 'commands')).reverse())
|
|
128
|
+
candidates.push(path.join(claudeConfigDir(home), 'commands'), path.join(path.dirname(managedSettingsFile()), '.claude', 'commands'))
|
|
127
129
|
const dirs: string[] = []
|
|
128
130
|
for (const dir of candidates) {
|
|
129
131
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -234,7 +236,7 @@ export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, a
|
|
|
234
236
|
return { stdout, stderr: result.stderr, code: result.code }
|
|
235
237
|
}
|
|
236
238
|
: async () => ({ stdout: SHELL_DISABLED_PLACEHOLDER, stderr: '', code: 0 })
|
|
237
|
-
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec)
|
|
239
|
+
let expanded = await expandDynamicContent(withVars, ctx.cwd, exec, parsed.shell === 'powershell' ? 'powershell' : 'bash')
|
|
238
240
|
|
|
239
241
|
// Claude appends the raw arguments when the command never read them, so what
|
|
240
242
|
// the user typed still reaches the model.
|
|
@@ -270,23 +272,22 @@ export function slashCommandBudget(contextWindow: number | undefined, env: Recor
|
|
|
270
272
|
/** The slash_command tool description: usage framing plus the budgeted command
|
|
271
273
|
* list, each entry `/name - description (argument-hint)`. */
|
|
272
274
|
export function slashCommandToolDescription(commands: SlashCommandEntry[], budget: number): string {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
// Claude: "The listing always contains every skill name"; the budget shortens
|
|
276
|
+
// descriptions (later entries lose theirs first), never drops a name, so an
|
|
277
|
+
// omitted-but-invocable command can no longer contradict the listing.
|
|
278
|
+
const lines = commands.map((command) => `/${command.name}`)
|
|
279
|
+
let used = lines.reduce((total, line) => total + line.length + 1, 0)
|
|
280
|
+
for (const [index, command] of commands.entries()) {
|
|
277
281
|
const hintSuffix = command.argumentHint ? ` (${command.argumentHint})` : ''
|
|
278
282
|
// when_to_use is model-facing trigger text, appended after the description and before
|
|
279
283
|
// the argument hint; it shares the per-entry cap and never reaches the user surface.
|
|
280
284
|
const whenSuffix = command.whenToUse ? ` ${command.whenToUse}` : ''
|
|
281
285
|
const entry = `/${command.name} - ${command.description}${whenSuffix}${hintSuffix}`.slice(0, ENTRY_CHAR_CAP)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
used += entry.length + 1
|
|
287
|
-
lines.push(entry)
|
|
286
|
+
const growth = entry.length - lines[index].length
|
|
287
|
+
if (used + growth > budget) continue
|
|
288
|
+
used += growth
|
|
289
|
+
lines[index] = entry
|
|
288
290
|
}
|
|
289
|
-
if (omitted > 0) lines.push(`(${omitted} more ${omitted === 1 ? 'command was' : 'commands were'} omitted: raise SLASH_COMMAND_TOOL_CHAR_BUDGET to list them)`)
|
|
290
291
|
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')
|
|
291
292
|
}
|
|
292
293
|
|
|
@@ -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
|
|
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. */
|
|
@@ -394,9 +394,11 @@ export function substituteVars(text: string, vars: Record<string, string | undef
|
|
|
394
394
|
return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
/**
|
|
397
|
+
/** Claude: "You invoke a command file by its file name"; subdirectories organize
|
|
398
|
+
* files without namespacing, so `a/b/c.md` is `/c`. A same-name file in another
|
|
399
|
+
* subdirectory takes the name over (scan order decides), as with Claude. */
|
|
398
400
|
export function commandNameFor(relativePath: string): string {
|
|
399
|
-
return
|
|
401
|
+
return path.basename(relativePath, '.md')
|
|
400
402
|
}
|
|
401
403
|
|
|
402
404
|
/** Every `*.md` under a commands directory, including nested ones. */
|
|
@@ -537,16 +539,21 @@ function fencedRanges(body: string): Array<[number, number]> {
|
|
|
537
539
|
}
|
|
538
540
|
|
|
539
541
|
/** Exit 1 is a normal result for Claude's documented search and comparison commands
|
|
540
|
-
* (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). */
|
|
541
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'])
|
|
542
547
|
|
|
543
|
-
|
|
548
|
+
export type SpanShell = 'bash' | 'powershell'
|
|
549
|
+
|
|
550
|
+
const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
|
|
544
551
|
const words = segment.trim().split(/\s+/)
|
|
545
552
|
if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
|
|
546
|
-
return EXIT_ONE_OK.has(words[0])
|
|
553
|
+
return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
|
|
547
554
|
}
|
|
548
555
|
|
|
549
|
-
function benignExitOne(command: string): boolean {
|
|
556
|
+
export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
|
|
550
557
|
const segments = splitSegments(command)
|
|
551
558
|
if (segments.length === 0) return false
|
|
552
559
|
// A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
|
|
@@ -555,15 +562,15 @@ function benignExitOne(command: string): boolean {
|
|
|
555
562
|
// the exit benign whichever ran last. Without short-circuit operators the exit is
|
|
556
563
|
// the last segment's (a `|` pipeline exits with its final command, `;`/newline with
|
|
557
564
|
// the last statement), so the last segment decides.
|
|
558
|
-
if (/&&|\|\|/.test(command)) return segments.every(isCarveoutSegment)
|
|
559
|
-
return isCarveoutSegment(segments.at(-1) ?? '')
|
|
565
|
+
if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
|
|
566
|
+
return isCarveoutSegment(segments.at(-1) ?? '', shell)
|
|
560
567
|
}
|
|
561
568
|
|
|
562
569
|
/** Run one injected span. A failure aborts the whole invocation, as Claude
|
|
563
570
|
* documents: the model never sees a half-expanded body. */
|
|
564
|
-
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> {
|
|
565
572
|
const result = await exec(command)
|
|
566
|
-
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command))) {
|
|
573
|
+
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
567
574
|
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
568
575
|
}
|
|
569
576
|
return result.stdout.trimEnd()
|
|
@@ -606,7 +613,7 @@ interface DynamicSpan {
|
|
|
606
613
|
* never re-scanned for further placeholders. Re-scanning was both a parity break
|
|
607
614
|
* (Claude expands once) and a command-injection path: output of a `` ```! `` block
|
|
608
615
|
* such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
|
|
609
|
-
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> {
|
|
610
617
|
const blocks = fenceBlocks(body)
|
|
611
618
|
const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
|
|
612
619
|
const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
|
|
@@ -616,14 +623,14 @@ export async function expandDynamicContent(body: string, cwd: string, exec: Comm
|
|
|
616
623
|
|
|
617
624
|
const spans: DynamicSpan[] = []
|
|
618
625
|
for (const block of blocks) {
|
|
619
|
-
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) })
|
|
620
627
|
}
|
|
621
628
|
// `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
|
|
622
629
|
const bashPattern = /(^|\s)!`([^`]+)`/g
|
|
623
630
|
for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
|
|
624
631
|
if (literal(m.index)) continue
|
|
625
632
|
const [span, lead, command] = m
|
|
626
|
-
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)) })
|
|
627
634
|
}
|
|
628
635
|
const atPattern = /(^|\s)@(\S+)/g
|
|
629
636
|
for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
|
|
@@ -7,20 +7,38 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from 'node:fs'
|
|
9
9
|
|
|
10
|
+
// Captured at module load: the poll must run on real time even under a test's
|
|
11
|
+
// fake timers (the stat watcher it replaced lived in libuv and was immune too);
|
|
12
|
+
// otherwise fake-timer advances spin the poll and freeze real detection.
|
|
13
|
+
const realSetInterval = globalThis.setInterval
|
|
14
|
+
const realClearInterval = globalThis.clearInterval
|
|
15
|
+
|
|
16
|
+
/** One file's content, or undefined when absent or unreadable. */
|
|
17
|
+
function snapshot(file: string): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readFileSync(file, 'utf-8')
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/** Watch the given settings files, calling `reload` when any of them changes.
|
|
11
|
-
* Returns a dispose function. The poll
|
|
26
|
+
* Returns a dispose function. The poll compares content, not stats: a same-size
|
|
27
|
+
* rewrite within one timestamp tick is invisible to an mtime comparison on a
|
|
28
|
+
* coarse-granularity filesystem, which made detection flaky. Settings files are
|
|
29
|
+
* small, so re-reading them on the poll is negligible. The interval is
|
|
30
|
+
* env-tunable for tests. */
|
|
12
31
|
export function watchSettingsFiles(files: string[], reload: () => void): () => void {
|
|
13
32
|
const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
33
|
+
let last = files.map(snapshot)
|
|
34
|
+
const timer = realSetInterval(() => {
|
|
35
|
+
const next = files.map(snapshot)
|
|
36
|
+
if (next.some((content, index) => content !== last[index])) {
|
|
37
|
+
last = next
|
|
38
|
+
reload()
|
|
18
39
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return () => {
|
|
24
|
-
for (const [file, listener] of listeners) fs.unwatchFile(file, listener)
|
|
25
|
-
}
|
|
40
|
+
}, interval)
|
|
41
|
+
// A watcher alone must never keep a one-shot run alive.
|
|
42
|
+
timer.unref?.()
|
|
43
|
+
return () => realClearInterval(timer)
|
|
26
44
|
}
|
package/extensions/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.38",
|
|
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",
|