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.
@@ -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 (registered as `/frontend:build`; current Claude docs name a command by
9
- * file name alone, so the qualified form is a pi-code divergence), and the
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, user first then project. The project
119
- * directory is the nearest at or above cwd (bounded at the repository root, matching
120
- * the approval walk) and is included only for approved projects. */
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 = [path.join(claudeConfigDir(home), 'commands')]
124
+ const candidates: string[] = []
123
125
  // Claude scans every .claude/commands between cwd and the repository root, the
124
- // nearest winning a name clash: collectCommands lets later directories win, so
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
- const lines: string[] = []
274
- let used = 0
275
- let omitted = 0
276
- for (const command of commands) {
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
- if (used + entry.length + 1 > budget) {
283
- omitted++
284
- continue // a shorter later entry may still fit the remaining budget
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'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. */
@@ -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
- /** `a/b/c.md` becomes Claude's `a:b:c`. */
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 relativePath.replace(/\.md$/, '').split(path.sep).join(':')
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
- const isCarveoutSegment = (segment: string): boolean => {
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 interval is env-tunable for tests. */
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
- const listeners: Array<[string, (curr: fs.Stats, prev: fs.Stats) => void]> = []
15
- for (const file of files) {
16
- const listener = (curr: fs.Stats, prev: fs.Stats): void => {
17
- if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) reload()
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
- // persistent: false, so a watcher alone never keeps a one-shot run alive.
20
- fs.watchFile(file, { interval, persistent: false }, listener)
21
- listeners.push([file, listener])
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
  }
@@ -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.36",
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",