pi-code 1.0.56 → 1.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/extensions/commands.ts +6 -12
  2. package/extensions/context-imports.ts +2 -10
  3. package/extensions/env-settings.ts +1 -5
  4. package/extensions/git-checkpoint.ts +4 -12
  5. package/extensions/goal.ts +2 -2
  6. package/extensions/hooks/config.ts +6 -21
  7. package/extensions/hooks/decisions.ts +3 -2
  8. package/extensions/hooks/index.ts +4 -16
  9. package/extensions/hooks/matcher.ts +2 -1
  10. package/extensions/hooks/runners.ts +4 -3
  11. package/extensions/internal/command-file.ts +7 -241
  12. package/extensions/internal/command-spans.ts +246 -0
  13. package/extensions/internal/managed-settings.ts +3 -5
  14. package/extensions/internal/plugins.ts +2 -2
  15. package/extensions/internal/settings-chain.ts +19 -0
  16. package/extensions/internal/values.ts +38 -0
  17. package/extensions/mcp/index.ts +6 -5
  18. package/extensions/mcp/listing.ts +2 -1
  19. package/extensions/mcp/oauth-flow.ts +2 -1
  20. package/extensions/mcp/policy.ts +9 -2
  21. package/extensions/memory.ts +10 -15
  22. package/extensions/output-styles.ts +4 -17
  23. package/extensions/plan-mode/index.ts +9 -9
  24. package/extensions/plan-mode/utils.ts +31 -0
  25. package/extensions/session-title.ts +2 -12
  26. package/extensions/skills.ts +6 -18
  27. package/extensions/status-line.ts +2 -8
  28. package/extensions/subagent/README.md +12 -2
  29. package/extensions/subagent/agents.ts +2 -2
  30. package/extensions/subagent/background.ts +2 -1
  31. package/extensions/subagent/child.ts +197 -0
  32. package/extensions/subagent/concurrency.ts +23 -0
  33. package/extensions/subagent/index.ts +36 -1426
  34. package/extensions/subagent/modes.ts +405 -0
  35. package/extensions/subagent/params.ts +56 -0
  36. package/extensions/subagent/registry-text.ts +105 -0
  37. package/extensions/subagent/render-result.ts +306 -0
  38. package/extensions/subagent/run.ts +375 -0
  39. package/extensions/subagent/types.ts +41 -0
  40. package/extensions/subagent/worktree.ts +2 -1
  41. package/package.json +1 -1
@@ -44,7 +44,8 @@ import * as path from 'node:path'
44
44
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
45
45
  import { Type } from 'typebox'
46
46
  import { matchesBashRules } from './internal/bash-rules.js'
47
- import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
47
+ import { type DiscoveredCommand, discoverCommandFiles, type ParsedCommand, type PathRuleTool, parseCommandFile, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
48
+ import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanExec } from './internal/command-spans.js'
48
49
  import { claudeConfigDir } from './internal/config-dir.js'
49
50
  import { claudeEffortLevel } from './internal/effort.js'
50
51
  import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
@@ -55,6 +56,7 @@ import { isProjectApproved } from './internal/project-approval.js'
55
56
  import { ancestorDirs, repoRoot } from './internal/project-root.js'
56
57
  import { claudeSettingsChain } from './internal/settings-chain.js'
57
58
  import { createTurnOverride } from './internal/turn-override.js'
59
+ import { errorMessage, isDirectory } from './internal/values.js'
58
60
 
59
61
  /** Just enough of pi's Model to match and restore; getAvailable returns these. */
60
62
  interface ModelLike {
@@ -105,14 +107,6 @@ interface VarContext {
105
107
  modelRegistry?: { getAvailable(): ReadonlyArray<ModelLike> }
106
108
  }
107
109
 
108
- function isDirectory(target: string): boolean {
109
- try {
110
- return fs.statSync(target).isDirectory()
111
- } catch {
112
- return false
113
- }
114
- }
115
-
116
110
  /** Existing `.claude/commands` directories in Claude's precedence order (later
117
111
  * directories win in collectCommands): project first, then personal, then the
118
112
  * enterprise directory beside the managed settings file, per "enterprise
@@ -328,7 +322,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
328
322
  // A refused switch leaves the turn on the session model rather than the one the
329
323
  // command named, and the reply gives no sign of it, so the refusal is reported.
330
324
  void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
331
- console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${error instanceof Error ? error.message : String(error)}`)
325
+ console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
332
326
  })
333
327
  },
334
328
  })
@@ -455,7 +449,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
455
449
  } catch (error) {
456
450
  // A failed injected command aborts the invocation; the model never sees a
457
451
  // half-expanded body. The notify carries Claude's failure message format.
458
- ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
452
+ ctx.ui.notify(errorMessage(error), 'error')
459
453
  return
460
454
  }
461
455
 
@@ -513,7 +507,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
513
507
  // An unreadable file must not take down session start, but the command is then
514
508
  // absent from /help and unresolvable by the model, which looks like one that was
515
509
  // never written.
516
- console.warn(`pi-code-commands: ignoring ${command.filePath}: ${error instanceof Error ? error.message : String(error)}`)
510
+ console.warn(`pi-code-commands: ignoring ${command.filePath}: ${errorMessage(error)}`)
517
511
  continue
518
512
  }
519
513
  discovered.set(command.name, command)
@@ -76,7 +76,7 @@ import { sliceBytes } from './internal/output-guard.js'
76
76
  import { globToRegExpSource } from './internal/path-rules.js'
77
77
  import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
78
78
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
79
- import { claudeSettingsChain } from './internal/settings-chain.js'
79
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
80
80
  import { statToken } from './internal/stat-token.js'
81
81
  import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
82
82
 
@@ -406,15 +406,7 @@ export function readClaudeMdExcludes(files: string[], managed: Record<string, un
406
406
  if (typeof entry === 'string' && entry.trim().length > 0) globs.push(entry)
407
407
  }
408
408
  }
409
- for (const file of files) {
410
- try {
411
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
412
- if (settings === null || typeof settings !== 'object') continue
413
- collect((settings as Record<string, unknown>).claudeMdExcludes)
414
- } catch {
415
- // missing or invalid settings file: skip
416
- }
417
- }
409
+ for (const settings of readSettingsChain(files)) collect(settings.claudeMdExcludes)
418
410
  collect(managed.claudeMdExcludes)
419
411
  return globs
420
412
  }
@@ -36,15 +36,11 @@ import * as fs from 'node:fs'
36
36
  import * as os from 'node:os'
37
37
  import * as path from 'node:path'
38
38
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
39
-
40
39
  import { claudeConfigDir } from './internal/config-dir.js'
41
40
  import { readManagedSettings } from './internal/managed-settings.js'
42
41
  import { isProjectApprovedSilently } from './internal/project-approval.js'
43
42
  import { claudeSettingsChain } from './internal/settings-chain.js'
44
-
45
- function isRecord(value: unknown): value is Record<string, unknown> {
46
- return typeof value === 'object' && value !== null && !Array.isArray(value)
47
- }
43
+ import { isRecord } from './internal/values.js'
48
44
 
49
45
  /** The `env` object of one settings scope, coerced to string values. A string is kept
50
46
  * as-is, a number or boolean becomes its String() form, and anything else (object,
@@ -19,6 +19,7 @@ import * as os from 'node:os'
19
19
  import * as path from 'node:path'
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
21
21
  import { claudeConfigDir } from './internal/config-dir.js'
22
+ import { contentText, errorMessage } from './internal/values.js'
22
23
 
23
24
  const CUSTOM_TYPE = 'git-checkpoint'
24
25
  /** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
@@ -116,17 +117,8 @@ function rememberWorkTree(shadowDir: string, cwd: string): void {
116
117
  }
117
118
  }
118
119
 
119
- function extractText(content: unknown): string {
120
- if (typeof content === 'string') return content
121
- if (!Array.isArray(content)) return ''
122
- return content
123
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
124
- .map((part) => part.text)
125
- .join(' ')
126
- }
127
-
128
120
  function promptSnippet(content: unknown): string {
129
- const text = extractText(content).replace(/\s+/g, ' ').trim()
121
+ const text = contentText(content, ' ').replace(/\s+/g, ' ').trim()
130
122
  if (text.length <= PROMPT_SNIPPET_LENGTH) return text
131
123
  return `${text.slice(0, PROMPT_SNIPPET_LENGTH)}…`
132
124
  }
@@ -156,7 +148,7 @@ async function restoreConversation(ctx: ExtensionCommandContext, entryId: string
156
148
  if (typeof result.editorText === 'string') ctx.ui.setEditorText(result.editorText)
157
149
  return true
158
150
  } catch (error) {
159
- const message = error instanceof Error ? error.message : String(error)
151
+ const message = errorMessage(error)
160
152
  ctx.ui.notify(`Conversation restore failed: ${message}`, 'error')
161
153
  return false
162
154
  }
@@ -237,7 +229,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
237
229
  } catch (error) {
238
230
  // Without the mirror, files the user excluded locally are snapshotted into the
239
231
  // checkpoint store and restored by /rewind, so this is not a silent fallback.
240
- ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${error instanceof Error ? error.message : String(error)}`, 'warning')
232
+ ctx.ui.notify(`Checkpoints cannot honor this repository's .git/info/exclude: ${errorMessage(error)}`, 'warning')
241
233
  }
242
234
  }
243
235
 
@@ -37,7 +37,6 @@
37
37
 
38
38
  import * as os from 'node:os'
39
39
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
40
-
41
40
  import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
42
41
  import {
43
42
  checkinIntervalMs,
@@ -66,6 +65,7 @@ import { completeText } from './internal/model-complete.js'
66
65
  import { resolveModelOverride } from './internal/model-lookup.js'
67
66
  import { isProjectApprovedSilently } from './internal/project-approval.js'
68
67
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
68
+ import { errorMessage } from './internal/values.js'
69
69
 
70
70
  /** Session entry type the goal state persists under, and the custom message type its
71
71
  * transcript lines (kickoff, verdicts, check-ins) carry. */
@@ -340,7 +340,7 @@ export default function goalExtension(pi: ExtensionAPI) {
340
340
  // A user interrupt is not an evaluator failure: the goal stays, nothing to say.
341
341
  if (ctx.signal?.aborted) return
342
342
  // No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
343
- if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${error instanceof Error ? error.message : String(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
343
+ if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${errorMessage(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
344
344
  return
345
345
  }
346
346
  // Interrupted, cleared, or replaced during the await: this verdict must not act.
@@ -8,7 +8,8 @@ import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
9
  import { readManagedSettings } from '../internal/managed-settings.js'
10
10
  import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
11
- import { claudeSettingsChain } from '../internal/settings-chain.js'
11
+ import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
12
+ import { errorMessage, isRecord } from '../internal/values.js'
12
13
 
13
14
  export interface HookCommand {
14
15
  type?: string
@@ -67,10 +68,6 @@ export function isBackgroundHook(hook: HookCommand): boolean {
67
68
  }
68
69
  export type HooksConfig = Record<string, HookMatcher[]>
69
70
 
70
- export function isRecord(value: unknown): value is Record<string, unknown> {
71
- return typeof value === 'object' && value !== null && !Array.isArray(value)
72
- }
73
-
74
71
  /** Settings files to read, newest-winning. Project files load only when trusted, each
75
72
  * the nearest of its name at or above cwd (bounded at the repository root, matching
76
73
  * the approval walk), so a subdirectory session reads the settings that gated it. */
@@ -92,13 +89,8 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
92
89
  * disableAllHooks cannot disable hooks configured through managed policy settings,
93
90
  * so the caller keeps managed hooks running when only this half is set. */
94
91
  export function readSettingsDisableAllHooks(files: string[]): boolean {
95
- for (const file of files) {
96
- try {
97
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
98
- if (isRecord(parsed) && parsed.disableAllHooks === true) return true
99
- } catch {
100
- // missing or invalid file: skip
101
- }
92
+ for (const settings of readSettingsChain(files)) {
93
+ if (settings.disableAllHooks === true) return true
102
94
  }
103
95
  return false
104
96
  }
@@ -152,14 +144,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
152
144
  found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
153
145
  }
154
146
  collect(managed.allowedHttpHookUrls)
155
- for (const file of files) {
156
- try {
157
- const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
158
- if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
159
- } catch {
160
- // missing or invalid file: skip
161
- }
162
- }
147
+ for (const settings of readSettingsChain(files)) collect(settings.allowedHttpHookUrls)
163
148
  return found
164
149
  }
165
150
 
@@ -203,7 +188,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
203
188
  } catch (error) {
204
189
  // Every hook this source declares is now absent, a policy hook among them, so the
205
190
  // failure is named rather than left to look like a file with no hooks in it.
206
- console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${error instanceof Error ? error.message : String(error)}`)
191
+ console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${errorMessage(error)}`)
207
192
  return
208
193
  }
209
194
  for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
@@ -6,8 +6,9 @@
6
6
 
7
7
  import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
8
8
  import type { PathAnchors } from '../internal/path-rules.js'
9
+ import { errorMessage, isRecord } from '../internal/values.js'
9
10
  import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
10
- import { type HookCommand, type HooksConfig, isRecord } from './config.js'
11
+ import type { HookCommand, HooksConfig } from './config.js'
11
12
  import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
12
13
  import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
13
14
 
@@ -63,7 +64,7 @@ export function hookJsonError(text: string): string | undefined {
63
64
  JSON.parse(trimmed)
64
65
  return undefined
65
66
  } catch (error) {
66
- parseError = error instanceof Error ? error.message : String(error)
67
+ parseError = errorMessage(error)
67
68
  }
68
69
  const lines = trimmed
69
70
  .split('\n')
@@ -109,6 +109,7 @@ import { watchSettingsFiles } from '../internal/settings-watch.js'
109
109
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
110
110
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
111
111
  import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
112
+ import { contentText } from '../internal/values.js'
112
113
  import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
113
114
  import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
114
115
  import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
@@ -127,12 +128,7 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
127
128
  for (let i = messages.length - 1; i >= 0; i--) {
128
129
  const message = messages[i]
129
130
  if (message.role !== 'assistant') continue
130
- if (typeof message.content === 'string') return message.content
131
- if (!Array.isArray(message.content)) return ''
132
- return message.content
133
- .filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
134
- .map((part) => part.text)
135
- .join('')
131
+ return contentText(message.content)
136
132
  }
137
133
  return ''
138
134
  }
@@ -157,14 +153,6 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
157
153
  return await Promise.all(runnable.map((command) => runner(command, payload, timeoutMs(command))))
158
154
  }
159
155
 
160
- /** The text of a result's content blocks, for Claude-shaped tool_response fields. */
161
- function textContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
162
- return content
163
- .filter((block) => block.type === 'text' && typeof block.text === 'string')
164
- .map((block) => block.text)
165
- .join('\n')
166
- }
167
-
168
156
  /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
169
157
  * offered both spellings so existing configs keep firing either way, and the payload
170
158
  * reports the Claude value, which is what a Claude-written hook script parses. */
@@ -531,13 +519,13 @@ export default function hooksExtension(pi: ExtensionAPI) {
531
519
  const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names).filter((command) => passesIfFilter(command, target))
532
520
  if (commands.length === 0 && pending.length === 0) return
533
521
  const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
534
- const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
522
+ const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, contentText(event.content, '\n'), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
535
523
  const startedAt = toolStartTimes.get(event.toolCallId)
536
524
  toolStartTimes.delete(event.toolCallId)
537
525
  // Claude delivers a failure as top-level fields rather than a tool_response: "error
538
526
  // information as top-level fields ... error ... is_interrupt". is_interrupt is false
539
527
  // here because pi reports a cancelled tool through the result, not this event.
540
- const failure = event.isError ? { error: textContent(event.content), is_interrupt: false } : { tool_response: response }
528
+ const failure = event.isError ? { error: contentText(event.content, '\n'), is_interrupt: false } : { tool_response: response }
541
529
  const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, ...failure, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
542
530
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
543
531
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { matchesBashIfFilter } from '../internal/bash-rules.js'
8
8
  import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
9
+ import { errorMessage } from '../internal/values.js'
9
10
  import type { HookCommand, HookMatcher } from './config.js'
10
11
 
11
12
  /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
@@ -62,7 +63,7 @@ function compileMatcher(matcher: string): CompiledMatcher {
62
63
  } catch (error) {
63
64
  // The fallback matches the literal text, which almost never matches a tool name, so
64
65
  // the hook simply never fires. Say so: the matcher reads as merely wrong otherwise.
65
- console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${error instanceof Error ? error.message : String(error)}); it will only match a tool of that exact name`)
66
+ console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${errorMessage(error)}); it will only match a tool of that exact name`)
66
67
  compiled = { tokens: exactTokens(matcher) }
67
68
  }
68
69
  }
@@ -11,6 +11,7 @@ import { runAgent } from '../internal/agent-run.js'
11
11
  import { callMcpTool } from '../internal/mcp-call.js'
12
12
  import { completeText } from '../internal/model-complete.js'
13
13
  import { resolveShell } from '../internal/shell-resolve.js'
14
+ import { errorMessage } from '../internal/values.js'
14
15
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
15
16
 
16
17
  // Claude's defaults vary by type and event (600s for command/http/mcp_tool, 30s
@@ -228,7 +229,7 @@ export async function runHttpHook(hook: { type?: string; command: string; url?:
228
229
  // event fails closed on it, exactly as a command hook that ran out of time does: the
229
230
  // hook never answered, whichever transport it used.
230
231
  const name = error instanceof Error ? error.name : ''
231
- return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: name === 'TimeoutError' || name === 'AbortError' }
232
+ return { code: 1, stdout: '', stderr: errorMessage(error), timedOut: name === 'TimeoutError' || name === 'AbortError' }
232
233
  }
233
234
  }
234
235
 
@@ -262,7 +263,7 @@ function substituteArguments(prompt: string | undefined, payload: unknown): stri
262
263
  * produced no verdict and is non-blocking. */
263
264
  function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
264
265
  const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
265
- return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
266
+ return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: errorMessage(error), timedOut: aborted }
266
267
  }
267
268
 
268
269
  export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
@@ -328,7 +329,7 @@ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeou
328
329
  })
329
330
  const call = callMcpTool(hook.server, hook.tool, input)
330
331
  .then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
331
- .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
332
+ .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: errorMessage(error), timedOut: false }))
332
333
  try {
333
334
  return await Promise.race([call, deadline])
334
335
  } finally {
@@ -2,9 +2,13 @@
2
2
  * Parsing and discovery for Claude Code slash-command files.
3
3
  *
4
4
  * pi's own prompt-template loader reads only `description` and `argument-hint`
5
- * from one flat directory, so the rest of Claude's command contract (namespaced
6
- * subdirectories, `allowed-tools`, `model`, `!` bash blocks, `@file` refs) lives
7
- * here and is applied by commands.ts when it registers each command itself.
5
+ * from one flat directory, so the rest of Claude's command contract lives here and is
6
+ * applied by commands.ts when it registers each command itself: namespaced
7
+ * subdirectories, `allowed-tools`, `model`, and the argument substitutions.
8
+ *
9
+ * Running what a body carries, the `!` bash blocks and `@file` references, is the other
10
+ * half and lives in command-spans.ts; skills.ts and the subagent loader import this file
11
+ * alone and never pull a shell resolver in.
8
12
  *
9
13
  * Docs: https://code.claude.com/docs/en/slash-commands.md
10
14
  */
@@ -13,8 +17,6 @@ import * as fs from 'node:fs'
13
17
  import * as path from 'node:path'
14
18
 
15
19
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
- import { splitSegments } from './shell-split.js'
17
- import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
18
20
 
19
21
  /** The pi file tools a Claude path rule can govern. */
20
22
  export type PathRuleTool = 'read' | 'edit' | 'write'
@@ -427,239 +429,3 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
427
429
  walk(root, '')
428
430
  return found
429
431
  }
430
-
431
- export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
432
-
433
- /** PowerShell single-quote escaping: inside a '...' literal the only special
434
- * characters are the quote delimiters themselves, written doubled. PowerShell's
435
- * lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
436
- * same way; leaving them bare let a projectDir like `Alex’s Projects` end the
437
- * literal mid-path with a ParserError. sh's '\'' form must not be used here,
438
- * since PowerShell would keep the backslash and reopen the string. */
439
- export function powershellQuote(value: string): string {
440
- return value.replaceAll(/['‘’‚‛]/g, '$&$&')
441
- }
442
-
443
- import { bashBinary } from './shell-resolve.js'
444
-
445
- export { resolvePowershellBinary } from './shell-resolve.js'
446
-
447
- export interface SpanExec {
448
- command: string
449
- args: string[]
450
- /** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
451
- * native command's stderr from `& { } 2>&1`), asking the caller to append the
452
- * exec result's stderr to its stdout instead. The sh path merges in-script and
453
- * leaves this unset. */
454
- mergeStreams?: boolean
455
- }
456
-
457
- /** The sh invocation for a span: CLAUDE_PROJECT_DIR and CLAUDECODE=1 exported in-script
458
- * (pi.exec takes no env; CLAUDECODE marks every subprocess Claude spawns), stderr merged
459
- * with 2>&1. The group opens with a `:` null command: `{ }` around an empty or
460
- * comment-only span is a hard sh syntax error (exit 2) that aborted the whole
461
- * invocation, and `:` keeps such a span the harmless no-op it was on HEAD while the
462
- * group still merges stderr for real spans. */
463
- function shSpan(binary: string, projectDir: string, script: string): SpanExec {
464
- const quoted = projectDir.replaceAll("'", String.raw`'\''`)
465
- return { command: binary, args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
466
- }
467
-
468
- /** The PowerShell invocation for a span. No in-script 2>&1: under pwsh 7 it does not
469
- * merge a native command's stderr on a script block, so mergeStreams has the caller
470
- * append it. The trailing exit forwards a failed native command's code, which pwsh
471
- * -Command otherwise swallows (the process exited 0 and a failure never aborted the
472
- * invocation). An empty or cmdlet-only span leaves $LASTEXITCODE unset and exits 0.
473
- * Residual gap vs sh: a failing cmdlet sets no exit code, so it cannot abort; its
474
- * error text still reaches the model through the merged stderr. */
475
- function powershellSpan(binary: string, projectDir: string, script: string): SpanExec {
476
- const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
477
- return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
478
- }
479
-
480
- /**
481
- * The exec invocation for one injected span, honoring the `shell:` frontmatter per
482
- * Claude's shell matrix (skills.md). `powershell` runs through a PowerShell binary when
483
- * one resolves. Otherwise the span runs through bash: /bin/sh off Windows, Git Bash on
484
- * Windows. Without Git Bash, a skill that declared `shell: bash` fails before any
485
- * command runs ("requires bash"), an undeclared one falls to PowerShell, and with
486
- * neither shell the invocation fails. Both paths export CLAUDE_PROJECT_DIR (each
487
- * shell's own quoting) and merge stderr into stdout, as the Bash tool does when it
488
- * runs these for Claude: the sh script in-line with 2>&1, the pwsh path via
489
- * mergeStreams in the caller.
490
- *
491
- * The resolvers are parameters so a caller (or test) controls the lookups: the
492
- * PowerShell one is passed as an imported binding, the bash one defaults to the
493
- * platform rule.
494
- */
495
- export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined, resolveBash: () => string | undefined = bashBinary): SpanExec {
496
- if (shell === 'powershell') {
497
- const binary = resolveBinary()
498
- if (binary !== undefined) return powershellSpan(binary, projectDir, script)
499
- }
500
- const bash = resolveBash()
501
- if (bash !== undefined) return shSpan(bash, projectDir, script)
502
- if (shell === 'bash') throw new Error('shell: bash requires Git Bash, which was not found (install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH)')
503
- const binary = resolveBinary()
504
- if (binary !== undefined) return powershellSpan(binary, projectDir, script)
505
- throw new Error('no shell found for the injected commands: install Git for Windows or PowerShell')
506
- }
507
-
508
- interface FenceBlock {
509
- start: number
510
- end: number
511
- /** A fence opened with ```! runs its content as one script; any other fence protects. */
512
- exec: boolean
513
- content: string
514
- }
515
-
516
- /** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
517
- * fence, while a ```! fence is itself a placeholder that executes. Fences follow
518
- * CommonMark: any indentation, closed only by the opener's character in a run at
519
- * least as long, so a tilde line or a shorter fence inside stays content. */
520
- function fenceBlocks(body: string): FenceBlock[] {
521
- const blocks: FenceBlock[] = []
522
- let fence: Fence | null = null
523
- let open: { index: number; exec: boolean; contentStart: number } | undefined
524
- let offset = 0
525
- for (const line of body.split('\n')) {
526
- const trimmed = line.trimStart()
527
- const step = stepFence(fence, trimmed, fenceMarker(trimmed))
528
- const lineEnd = offset + line.length
529
- if (fence === null && step.fence !== null) {
530
- // Only the exact, unindented ```! opener executes, as Claude documents it.
531
- open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
532
- } else if (fence !== null && step.fence === null && open !== undefined) {
533
- blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
534
- open = undefined
535
- }
536
- fence = step.fence
537
- offset = lineEnd + 1
538
- }
539
- // An unterminated fence protects to the end of the body rather than executing.
540
- if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
541
- return blocks
542
- }
543
-
544
- /** Exit 1 is a normal result for Claude's documented search and comparison commands
545
- * (no matches, files differ); exit 2 and up fails even for these. The PowerShell
546
- * shell uses a different set, which "includes grep and git diff but not find or
547
- * diff" (test/[ are bash builtins and do not apply there either). */
548
- const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
549
- const EXIT_ONE_OK_POWERSHELL = new Set(['grep', 'rg', 'egrep', 'fgrep'])
550
-
551
- export type SpanShell = 'bash' | 'powershell'
552
-
553
- const isCarveoutSegment = (segment: string, shell: SpanShell): boolean => {
554
- const words = segment.trim().split(/\s+/)
555
- if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
556
- return (shell === 'powershell' ? EXIT_ONE_OK_POWERSHELL : EXIT_ONE_OK).has(words[0])
557
- }
558
-
559
- export function benignExitOne(command: string, shell: SpanShell = 'bash'): boolean {
560
- const segments = splitSegments(command)
561
- if (segments.length === 0) return false
562
- // A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
563
- // result and the last segment is not the one that set the code: `cd nope && grep x`
564
- // exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
565
- // the exit benign whichever ran last. Without short-circuit operators the exit is
566
- // the last segment's (a `|` pipeline exits with its final command, `;`/newline with
567
- // the last statement), so the last segment decides.
568
- if (/&&|\|\|/.test(command)) return segments.every((segment) => isCarveoutSegment(segment, shell))
569
- return isCarveoutSegment(segments.at(-1) ?? '', shell)
570
- }
571
-
572
- /** Run one injected span. A failure aborts the whole invocation, as Claude
573
- * documents: the model never sees a half-expanded body. */
574
- async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
575
- const result = await exec(command)
576
- // A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
577
- // so the code alone would paste the partial output as a success. Claude kills a span
578
- // at the Bash timeout and that failure aborts the invocation.
579
- if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
580
- if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
581
- throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
582
- }
583
- return result.stdout.trimEnd()
584
- }
585
-
586
- const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
587
-
588
- /** Read a `@path` reference, confined to the working directory. Returns undefined
589
- * when the path escapes it or cannot be read, so the reference stays literal. */
590
- function readReference(cwd: string, reference: string): string | undefined {
591
- try {
592
- // Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
593
- // resolved path against an unresolved root rejects every legitimate read.
594
- const root = fs.realpathSync(cwd)
595
- // Confinement is checked after symlinks resolve: a lexical check passes a link
596
- // that points outside the project, and the read would follow it.
597
- const real = fs.realpathSync(path.resolve(cwd, reference))
598
- if (real !== root && !real.startsWith(root + path.sep)) return undefined
599
- if (!fs.statSync(real).isFile()) return undefined
600
- return fs.readFileSync(real, 'utf-8')
601
- } catch {
602
- return undefined
603
- }
604
- }
605
-
606
- interface DynamicSpan {
607
- start: number
608
- end: number
609
- run: () => Promise<string>
610
- }
611
-
612
- /** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
613
- * its output (recognized only at a word start), a ```! fenced block runs its lines
614
- * as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
615
- * inside plain fenced code blocks. A failed command rejects, aborting the
616
- * invocation, per the skills docs.
617
- *
618
- * Every placeholder is located in the ORIGINAL body and the whole body is expanded
619
- * in one pass, so a command's output (or a file's content) is inserted verbatim and
620
- * never re-scanned for further placeholders. Re-scanning was both a parity break
621
- * (Claude expands once) and a command-injection path: output of a `` ```! `` block
622
- * such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
623
- export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec, shell: SpanShell = 'bash'): Promise<string> {
624
- const blocks = fenceBlocks(body)
625
- const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
626
- const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
627
- // An inline span or @ ref inside a ```! block is part of that block's script, not a
628
- // placeholder of its own; the block already covers those bytes.
629
- const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
630
-
631
- const spans: DynamicSpan[] = []
632
- for (const block of blocks) {
633
- if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!', shell) })
634
- }
635
- // `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
636
- const bashPattern = /(^|\s)!`([^`]+)`/g
637
- for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
638
- if (literal(m.index)) continue
639
- const [span, lead, command] = m
640
- spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``, shell)) })
641
- }
642
- const atPattern = /(^|\s)@(\S+)/g
643
- for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
644
- if (literal(m.index)) continue
645
- const [whole, lead, reference] = m
646
- spans.push({
647
- start: m.index,
648
- end: m.index + whole.length,
649
- run: async () => {
650
- const content = readReference(cwd, reference)
651
- return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
652
- },
653
- })
654
- }
655
-
656
- spans.sort((a, b) => a.start - b.start)
657
- let out = ''
658
- let cursor = 0
659
- for (const span of spans) {
660
- if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
661
- out += body.slice(cursor, span.start) + (await span.run())
662
- cursor = span.end
663
- }
664
- return out + body.slice(cursor)
665
- }