pi-code 1.0.3 → 1.0.5

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 (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. package/package.json +1 -1
@@ -14,19 +14,34 @@ import * as path from 'node:path'
14
14
 
15
15
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
16
 
17
+ import { splitSegments } from './shell-split.js'
18
+
17
19
  export interface ParsedCommand {
18
20
  description: string
19
21
  argumentHint?: string
20
22
  allowedTools?: string[]
23
+ /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
24
+ bashRules?: string[]
25
+ /** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
26
+ pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
27
+ /** Names from the `arguments:` frontmatter list, mapped to positions in order. */
28
+ argumentNames?: string[]
29
+ /** Tools removed from the pool while the command's turn runs. */
30
+ disallowedTools?: string[]
31
+ /** `shell:` frontmatter: `bash` (the default) or `powershell`, choosing how the
32
+ * command's injected spans run (see spanExec). */
33
+ shell?: string
21
34
  model?: string
22
35
  disableModelInvocation: boolean
23
36
  body: string
24
37
  }
25
38
 
26
39
  export interface DiscoveredCommand {
27
- /** Claude's namespaced name: a nested file is `dir:name`. */
40
+ /** Claude's namespaced name: a nested file is `dir:name`, a plugin's is `plugin:name`. */
28
41
  name: string
29
42
  filePath: string
43
+ /** Set for plugin commands, carrying the ${CLAUDE_PLUGIN_*} and ${user_config.*} substitution sources. */
44
+ plugin?: { root: string; dataDir: string; userConfig?: Record<string, string> }
30
45
  }
31
46
 
32
47
  /** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
@@ -54,11 +69,11 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
54
69
  }
55
70
 
56
71
  /**
57
- * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
58
- * pi's active-tool list is per tool, with no argument dimension, so the scope is
59
- * dropped and the base tool is granted. Keeping the scope in the name matched nothing
60
- * when the list was intersected with the active tools, which left a command declaring
61
- * only scoped grants running with no tools at all.
72
+ * The pi tool name for one grant entry, scope and all: `Bash(git add:*)` is `bash`.
73
+ * Keeping the scope in the name matched nothing when the list was intersected with
74
+ * the active tools, which left a command declaring only scoped grants running with
75
+ * no tools at all. The scope itself is not dropped: parseToolGrants keeps bash
76
+ * scopes for call-time enforcement.
62
77
  */
63
78
  export function normalizeToolName(name: string): string {
64
79
  const lower = name.trim().toLowerCase()
@@ -94,19 +109,82 @@ export function toolEntries(raw: string): string[] {
94
109
  return entries.map((entry) => entry.trim()).filter(Boolean)
95
110
  }
96
111
 
112
+ export interface ToolGrants {
113
+ /** pi tool names to grant, deduplicated in first-seen order. */
114
+ tools: string[]
115
+ /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped:
116
+ * an unscoped `Bash` entry is the wider grant and wins over its scoped siblings. */
117
+ bashRules?: string[]
118
+ /** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
119
+ * Edit scopes govern writes too, as Claude documents; Write scopes are honored
120
+ * rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
121
+ pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
122
+ /** Entries that carried an argument scope, in their original spelling. */
123
+ scopedEntries: string[]
124
+ }
125
+
126
+ /** The pi file tools one Claude path-ruled entry governs. */
127
+ const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
128
+ read: ['read'],
129
+ edit: ['edit', 'write'],
130
+ write: ['write'],
131
+ }
132
+
97
133
  /**
98
134
  * A tool grant is either a comma-separated string or a YAML list, and the two mean the
99
135
  * same thing. An empty list is not the same as an absent one: it says no tools, so it
100
- * comes back as an empty array rather than undefined.
136
+ * comes back with an empty `tools` array rather than as undefined.
137
+ *
138
+ * Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
139
+ * pi's active-tool list is per tool, with no argument dimension, so the base tool is
140
+ * granted and the scope is kept: commands.ts enforces bash scopes at tool_call time,
141
+ * and the subagent's frontmatter parsing rejects a scoped grant it cannot express.
142
+ * A scope on any other tool is dropped, which widens that grant; bash is the one
143
+ * whose widening reaches everything, so it is the one enforced.
101
144
  */
102
- export function parseToolList(raw: unknown): string[] | undefined {
145
+ export function parseToolGrants(raw: unknown): ToolGrants | undefined {
103
146
  if (raw === undefined || raw === null) return undefined
104
147
  let items: unknown[]
105
148
  if (Array.isArray(raw)) items = raw
106
149
  else if (typeof raw === 'string') items = toolEntries(raw)
107
150
  else return undefined
108
151
  if (items.some((item) => typeof item !== 'string')) return undefined
109
- return [...new Set((items as string[]).map(normalizeToolName).filter(Boolean))]
152
+
153
+ const tools: string[] = []
154
+ const scopedEntries: string[] = []
155
+ const bashRules: string[] = []
156
+ let bashUnscoped = false
157
+ const pathScopes: Record<'read' | 'edit' | 'write', string[]> = { read: [], edit: [], write: [] }
158
+ const pathUnscoped = new Set<'read' | 'edit' | 'write'>()
159
+ for (const item of items as string[]) {
160
+ const entry = item.trim()
161
+ const name = normalizeToolName(entry)
162
+ if (!name) continue
163
+ if (!tools.includes(name)) tools.push(name)
164
+ const open = entry.indexOf('(')
165
+ if (open === -1) {
166
+ if (name === 'bash') bashUnscoped = true
167
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) pathUnscoped.add(tool)
168
+ continue
169
+ }
170
+ scopedEntries.push(entry)
171
+ const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
172
+ // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
173
+ // the unscoped grant it explicitly is not: it is recorded so the tool stays
174
+ // restricted, and the matchers treat an empty rule as matching no input.
175
+ if (name === 'bash') bashRules.push(scope)
176
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) pathScopes[tool].push(scope)
177
+ }
178
+ const pathRules: ToolGrants['pathRules'] = {}
179
+ for (const tool of ['read', 'edit', 'write'] as const) {
180
+ if (!pathUnscoped.has(tool) && pathScopes[tool].length > 0) pathRules[tool] = pathScopes[tool]
181
+ }
182
+ return {
183
+ tools,
184
+ scopedEntries,
185
+ bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
186
+ pathRules: Object.keys(pathRules).length > 0 ? pathRules : undefined,
187
+ }
110
188
  }
111
189
 
112
190
  /** YAML types a bare scalar, so a model named `3.5` arrives as a number, not a string. */
@@ -118,6 +196,22 @@ const text = (value: unknown): string => {
118
196
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
119
197
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
120
198
 
199
+ const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
200
+
201
+ /** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
202
+ * of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
203
+ * itself is reserved by the built-in placeholder. */
204
+ function parseArgumentNames(raw: unknown): string[] | undefined {
205
+ let items: string[]
206
+ if (Array.isArray(raw)) items = raw.map((entry) => String(entry))
207
+ else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
208
+ else return undefined
209
+ const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
210
+ return names.length > 0 ? names : undefined
211
+ }
212
+
213
+ const SHELLS = new Set(['bash', 'powershell'])
214
+
121
215
  export function parseCommandFile(content: string): ParsedCommand {
122
216
  // pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
123
217
  // command files actually use (flow sequences, block lists, quoted and multi-line
@@ -126,10 +220,18 @@ export function parseCommandFile(content: string): ParsedCommand {
126
220
  const body = raw.trim()
127
221
  const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
128
222
  const disable = frontmatter['disable-model-invocation']
223
+ const grants = parseToolGrants(frontmatter['allowed-tools'])
224
+ const shell = text(frontmatter.shell).toLowerCase()
129
225
  return {
130
226
  description: text(frontmatter.description) || firstLine.slice(0, 60),
131
227
  argumentHint: hint(frontmatter['argument-hint']) || undefined,
132
- allowedTools: parseToolList(frontmatter['allowed-tools']),
228
+ allowedTools: grants?.tools,
229
+ bashRules: grants?.bashRules,
230
+ pathRules: grants?.pathRules,
231
+ argumentNames: parseArgumentNames(frontmatter.arguments),
232
+ // A scope on a disallow entry only denies more than asked, so the drop is safe.
233
+ disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
234
+ shell: SHELLS.has(shell) ? shell : undefined,
133
235
  model: text(frontmatter.model) || undefined,
134
236
  disableModelInvocation: disable === true || text(disable) === 'true',
135
237
  body,
@@ -148,16 +250,80 @@ export function splitArgs(args: string): string[] {
148
250
  return out
149
251
  }
150
252
 
151
- /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`. An
152
- * unfilled positional becomes empty rather than leaking its literal token. */
153
- export function substituteArgs(body: string, args: string): string {
253
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
254
+
255
+ /** One alternation covering every argument placeholder plus the two escape forms.
256
+ * Alternation order is load-bearing: escapes first (so `\$1` never expands), the
257
+ * bracketed forms before `$ARGUMENTS` (so `$ARGUMENTS[0]` is not read as the bare
258
+ * placeholder plus literal brackets). `(?!)` never matches, standing in when no
259
+ * names are declared. */
260
+ function argPattern(names: string[]): RegExp {
261
+ const nameAlt = names.length > 0 ? names.map(escapeRegExp).join('|') : '(?!)'
262
+ return new RegExp(
263
+ String.raw`\\{2}(?=\$)` + // doubled backslash: both stay, the token after still expands
264
+ String.raw`|\\\$(?=\d|@|\{|ARGUMENTS\b|(?:${nameAlt})\b)` + // escape: before any placeholder this expands, incl. $@ and ${...:-}
265
+ String.raw`|\$ARGUMENTS\[(\d+)\]` +
266
+ String.raw`|\$\{(\d+):-([^}]*)\}` +
267
+ String.raw`|\$\{ARGUMENTS:-([^}]*)\}` +
268
+ String.raw`|\$ARGUMENTS\b` +
269
+ String.raw`|\$@` +
270
+ String.raw`|\$(\d+)` +
271
+ String.raw`|\$(${nameAlt})\b`,
272
+ 'g',
273
+ )
274
+ }
275
+
276
+ export interface SubstitutedArgs {
277
+ text: string
278
+ /** Whether any placeholder actually read the arguments; drives Claude's
279
+ * `ARGUMENTS: <value>` append when a command never looks at what was passed. */
280
+ consumed: boolean
281
+ }
282
+
283
+ /**
284
+ * Claude's substitutions, per the current skills docs: `$ARGUMENTS`,
285
+ * `$ARGUMENTS[N]` and its `$N` shorthand (0-based: `$0` is the first argument),
286
+ * `$name` for names declared in `arguments:` frontmatter, plus the pi extras `$@`
287
+ * and `${N:-default}`/`${ARGUMENTS:-default}`. An unfilled indexed placeholder
288
+ * stays literal; a declared name with no argument becomes empty; `\$` escapes only
289
+ * a real placeholder and a doubled backslash keeps both while still expanding.
290
+ * One pass with a replacer function: sequential string passes both interpreted
291
+ * `$&`-style metacharacters in the arguments and re-scanned substituted text.
292
+ */
293
+ export function substituteArgsDetailed(body: string, args: string, names: string[] = []): SubstitutedArgs {
154
294
  const parts = splitArgs(args)
155
- return body
156
- .replaceAll(/\$\{(\d+):-([^}]*)\}/g, (_m, index: string, fallback: string) => parts[Number(index) - 1] ?? fallback)
157
- .replaceAll(/\$\{ARGUMENTS:-([^}]*)\}/g, (_m, fallback: string) => (args.trim() ? args.trim() : fallback))
158
- .replaceAll(/\$ARGUMENTS\b/g, args.trim())
159
- .replaceAll('$@', args.trim())
160
- .replaceAll(/\$(\d+)/g, (_m, index: string) => parts[Number(index) - 1] ?? '')
295
+ const all = args.trim()
296
+ let consumed = false
297
+ const fill = (value: string | undefined, orElse: string): string => {
298
+ if (value === undefined) return orElse
299
+ consumed = true
300
+ return value
301
+ }
302
+ const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
303
+ if (token === '\\\\') return token
304
+ if (token === '\\$') return '$'
305
+ if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
306
+ if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
307
+ if (argsDefault !== undefined) {
308
+ consumed = true
309
+ return all || argsDefault
310
+ }
311
+ if (shorthandIdx !== undefined) return fill(parts[Number(shorthandIdx)], token)
312
+ if (name !== undefined) return fill(parts[names.indexOf(name)], '')
313
+ consumed = true
314
+ return all // $ARGUMENTS or $@
315
+ })
316
+ return { text, consumed }
317
+ }
318
+
319
+ export function substituteArgs(body: string, args: string, names: string[] = []): string {
320
+ return substituteArgsDetailed(body, args, names).text
321
+ }
322
+
323
+ /** Claude's `${CLAUDE_*}` string substitutions. A backslash does not prevent these,
324
+ * per the docs, and an unknown variable stays literal. */
325
+ export function substituteVars(text: string, vars: Record<string, string | undefined>): string {
326
+ return text.replaceAll(/\$\{(CLAUDE_[A-Z0-9_]+)\}/g, (token, name: string) => vars[name] ?? token)
161
327
  }
162
328
 
163
329
  /** `a/b/c.md` becomes Claude's `a:b:c`. */
@@ -187,23 +353,150 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
187
353
 
188
354
  export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
189
355
 
190
- /** Spans of a body that are inside a fenced code block, where Claude's dynamic
191
- * syntax is literal text rather than an instruction. */
192
- function fencedRanges(body: string): Array<[number, number]> {
193
- const ranges: Array<[number, number]> = []
194
- const fence = /^(```|~~~)[^\n]*$/gm
195
- let open: number | undefined
356
+ /** PowerShell single-quote escaping: inside a '...' literal the only special
357
+ * characters are the quote delimiters themselves, written doubled. PowerShell's
358
+ * lexer treats U+2018 through U+201B as single quotes too, so each is doubled the
359
+ * same way; leaving them bare let a projectDir like `Alex’s Projects` end the
360
+ * literal mid-path with a ParserError. sh's '\'' form must not be used here,
361
+ * since PowerShell would keep the backslash and reopen the string. */
362
+ export function powershellQuote(value: string): string {
363
+ return value.replaceAll(/['‘’‚‛]/g, '$&$&')
364
+ }
365
+
366
+ /** The PowerShell names worth trying: pwsh everywhere it installs, plus the
367
+ * Windows spellings on win32, where powershell.exe ships with the OS. */
368
+ const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
369
+
370
+ /** First PowerShell binary found on PATH, or undefined when none is installed. */
371
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
372
+ const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
373
+ for (const candidate of powershellCandidates(platform)) {
374
+ for (const dir of dirs) {
375
+ const full = path.join(dir, candidate)
376
+ try {
377
+ fs.accessSync(full, fs.constants.X_OK)
378
+ if (fs.statSync(full).isFile()) return full
379
+ } catch {
380
+ // not here; keep looking
381
+ }
382
+ }
383
+ }
384
+ return undefined
385
+ }
386
+
387
+ export interface SpanExec {
388
+ command: string
389
+ args: string[]
390
+ /** Set when the shell cannot merge stderr into stdout in-script (pwsh 7 drops a
391
+ * native command's stderr from `& { } 2>&1`), asking the caller to append the
392
+ * exec result's stderr to its stdout instead. The sh path merges in-script and
393
+ * leaves this unset. */
394
+ mergeStreams?: boolean
395
+ }
396
+
397
+ /**
398
+ * The exec invocation for one injected span, honoring the `shell:` frontmatter.
399
+ * The default (absent or `bash`) runs through /bin/sh; `powershell` resolves a
400
+ * PowerShell binary and runs the span with -Command, falling back to /bin/sh when
401
+ * none is installed so the command still works, per Claude's shell matrix. Both
402
+ * paths export CLAUDE_PROJECT_DIR (each shell's own quoting) and merge stderr
403
+ * into stdout, as the Bash tool does when it runs these for Claude: the sh script
404
+ * in-line with 2>&1, the pwsh path via mergeStreams in the caller.
405
+ *
406
+ * The resolver is a parameter rather than a default so the caller passes its own
407
+ * imported binding, which keeps the lookup mockable in tests.
408
+ */
409
+ export function spanExec(shell: string | undefined, projectDir: string, script: string, resolveBinary: () => string | undefined): SpanExec {
410
+ if (shell === 'powershell') {
411
+ const binary = resolveBinary()
412
+ if (binary !== undefined) {
413
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'`
414
+ // No in-script 2>&1: under pwsh 7 it does not merge a native command's
415
+ // stderr on a script block, so mergeStreams has the caller append it. The
416
+ // trailing exit forwards a failed native command's code, which pwsh
417
+ // -Command otherwise swallows (the process exited 0 and a failure never
418
+ // aborted the invocation). An empty or cmdlet-only span leaves
419
+ // $LASTEXITCODE unset and exits 0. Residual gap vs sh: a failing cmdlet
420
+ // sets no exit code, so it cannot abort; its error text still reaches the
421
+ // model through the merged stderr.
422
+ return { command: binary, args: ['-NoProfile', '-NonInteractive', '-Command', `${preamble}\n& {\n${script}\n}\nexit $LASTEXITCODE`], mergeStreams: true }
423
+ }
424
+ }
425
+ const quoted = projectDir.replaceAll("'", String.raw`'\''`)
426
+ // The group opens with a `:` null command: `{ }` around an empty or
427
+ // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
428
+ // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
429
+ // while the group still merges stderr for real spans.
430
+ return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
431
+ }
432
+
433
+ interface FenceBlock {
434
+ start: number
435
+ end: number
436
+ /** A fence opened with ```! runs its content as one script; any other fence protects. */
437
+ exec: boolean
438
+ content: string
439
+ }
440
+
441
+ /** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
442
+ * fence, while a ```! fence is itself a placeholder that executes. */
443
+ function fenceBlocks(body: string): FenceBlock[] {
444
+ const blocks: FenceBlock[] = []
445
+ const fence = /^(```|~~~)([^\n]*)$/gm
446
+ let open: { index: number; exec: boolean; contentStart: number } | undefined
196
447
  let match = fence.exec(body)
197
448
  while (match !== null) {
198
- if (open === undefined) open = match.index
199
- else {
200
- ranges.push([open, match.index + match[0].length])
449
+ if (open === undefined) {
450
+ open = { index: match.index, exec: match[1] === '```' && match[2].trim() === '!', contentStart: match.index + match[0].length + 1 }
451
+ } else {
452
+ blocks.push({ start: open.index, end: match.index + match[0].length, exec: open.exec, content: body.slice(Math.min(open.contentStart, match.index), match.index).replace(/\n$/, '') })
201
453
  open = undefined
202
454
  }
203
455
  match = fence.exec(body)
204
456
  }
205
- if (open !== undefined) ranges.push([open, body.length])
206
- return ranges
457
+ // An unterminated fence protects to the end of the body rather than executing.
458
+ if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
459
+ return blocks
460
+ }
461
+
462
+ /** Spans of a body inside a protective fenced code block. */
463
+ function fencedRanges(body: string): Array<[number, number]> {
464
+ return fenceBlocks(body)
465
+ .filter((block) => !block.exec)
466
+ .map((block) => [block.start, block.end])
467
+ }
468
+
469
+ /** Exit 1 is a normal result for Claude's documented search and comparison commands
470
+ * (no matches, files differ); exit 2 and up fails even for these. */
471
+ const EXIT_ONE_OK = new Set(['grep', 'rg', 'egrep', 'fgrep', 'find', 'diff', 'test', '['])
472
+
473
+ const isCarveoutSegment = (segment: string): boolean => {
474
+ const words = segment.trim().split(/\s+/)
475
+ if (words[0] === 'git') return words[1] === 'diff' || words[1] === 'grep'
476
+ return EXIT_ONE_OK.has(words[0])
477
+ }
478
+
479
+ function benignExitOne(command: string): boolean {
480
+ const segments = splitSegments(command)
481
+ if (segments.length === 0) return false
482
+ // A `&&`/`||` chain can short-circuit, so an earlier segment's exit 1 becomes the
483
+ // result and the last segment is not the one that set the code: `cd nope && grep x`
484
+ // exits 1 from cd, not a benign grep miss. Only when every segment is a carveout is
485
+ // the exit benign whichever ran last. Without short-circuit operators the exit is
486
+ // the last segment's (a `|` pipeline exits with its final command, `;`/newline with
487
+ // the last statement), so the last segment decides.
488
+ if (/&&|\|\|/.test(command)) return segments.every(isCarveoutSegment)
489
+ return isCarveoutSegment(segments.at(-1) ?? '')
490
+ }
491
+
492
+ /** Run one injected span. A failure aborts the whole invocation, as Claude
493
+ * documents: the model never sees a half-expanded body. */
494
+ async function runSpan(exec: CommandExec, command: string, pattern: string): Promise<string> {
495
+ const result = await exec(command)
496
+ if (result.code !== 0 && !(result.code === 1 && benignExitOne(command))) {
497
+ throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
498
+ }
499
+ return result.stdout.trimEnd()
207
500
  }
208
501
 
209
502
  const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ranges.some(([start, end]) => index >= start && index < end)
@@ -226,32 +519,63 @@ function readReference(cwd: string, reference: string): string | undefined {
226
519
  }
227
520
  }
228
521
 
522
+ interface DynamicSpan {
523
+ start: number
524
+ end: number
525
+ run: () => Promise<string>
526
+ }
527
+
229
528
  /** Claude's dynamic command content: `` !`cmd` `` runs a shell command and pastes
230
- * its output, `@path` inlines a file. Both are skipped inside fenced code blocks. */
529
+ * its output (recognized only at a word start), a ```! fenced block runs its lines
530
+ * as one script, and `@path` inlines a file. Inline spans and `@` refs are skipped
531
+ * inside plain fenced code blocks. A failed command rejects, aborting the
532
+ * invocation, per the skills docs.
533
+ *
534
+ * Every placeholder is located in the ORIGINAL body and the whole body is expanded
535
+ * in one pass, so a command's output (or a file's content) is inserted verbatim and
536
+ * never re-scanned for further placeholders. Re-scanning was both a parity break
537
+ * (Claude expands once) and a command-injection path: output of a `` ```! `` block
538
+ * such as a commit message could smuggle its own `` !`cmd` `` for a later pass. */
231
539
  export async function expandDynamicContent(body: string, cwd: string, exec: CommandExec): Promise<string> {
232
- const fenced = fencedRanges(body)
233
-
234
- const commands: Array<{ span: string; command: string; index: number }> = []
235
- const bashPattern = /!`([^`]+)`/g
236
- let bashMatch = bashPattern.exec(body)
237
- while (bashMatch !== null) {
238
- if (!inRanges(fenced, bashMatch.index)) commands.push({ span: bashMatch[0], command: bashMatch[1], index: bashMatch.index })
239
- bashMatch = bashPattern.exec(body)
240
- }
540
+ const blocks = fenceBlocks(body)
541
+ const protectedRanges = blocks.filter((block) => !block.exec).map((block): [number, number] => [block.start, block.end])
542
+ const execRanges = blocks.filter((block) => block.exec).map((block): [number, number] => [block.start, block.end])
543
+ // An inline span or @ ref inside a ```! block is part of that block's script, not a
544
+ // placeholder of its own; the block already covers those bytes.
545
+ const literal = (index: number): boolean => inRanges(protectedRanges, index) || inRanges(execRanges, index)
241
546
 
242
- let expanded = body
243
- for (const entry of commands) {
244
- const result = await exec(entry.command)
245
- const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
246
- expanded = expanded.replace(entry.span, output)
547
+ const spans: DynamicSpan[] = []
548
+ for (const block of blocks) {
549
+ if (block.exec) spans.push({ start: block.start, end: block.end, run: () => runSpan(exec, block.content, '```!') })
550
+ }
551
+ // `!` counts only at the start of a line or after whitespace; `KEY=!`cmd`` is literal.
552
+ const bashPattern = /(^|\s)!`([^`]+)`/g
553
+ for (let m = bashPattern.exec(body); m !== null; m = bashPattern.exec(body)) {
554
+ if (literal(m.index)) continue
555
+ const [span, lead, command] = m
556
+ spans.push({ start: m.index, end: m.index + span.length, run: async () => lead + (await runSpan(exec, command, `!\`${command}\``)) })
557
+ }
558
+ const atPattern = /(^|\s)@(\S+)/g
559
+ for (let m = atPattern.exec(body); m !== null; m = atPattern.exec(body)) {
560
+ if (literal(m.index)) continue
561
+ const [whole, lead, reference] = m
562
+ spans.push({
563
+ start: m.index,
564
+ end: m.index + whole.length,
565
+ run: async () => {
566
+ const content = readReference(cwd, reference)
567
+ return content === undefined ? whole : `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
568
+ },
569
+ })
247
570
  }
248
571
 
249
- // Ranges are recomputed: command output can change offsets.
250
- const fencedAfter = fencedRanges(expanded)
251
- return expanded.replaceAll(/(^|\s)@(\S+)/g, (whole, lead: string, reference: string, offset: number) => {
252
- if (inRanges(fencedAfter, offset)) return whole
253
- const content = readReference(cwd, reference)
254
- if (content === undefined) return whole
255
- return `${lead}\n<file path="${reference}">\n${content.trimEnd()}\n</file>\n`
256
- })
572
+ spans.sort((a, b) => a.start - b.start)
573
+ let out = ''
574
+ let cursor = 0
575
+ for (const span of spans) {
576
+ if (span.start < cursor) continue // a rare @/inline overlap: keep the first, skip the nested
577
+ out += body.slice(cursor, span.start) + (await span.run())
578
+ cursor = span.end
579
+ }
580
+ return out + body.slice(cursor)
257
581
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * HTML to markdown conversion for web_fetch, mirroring Claude's WebFetch, which
3
+ * converts pages to markdown before the model reads them.
4
+ *
5
+ * A regex pipeline, not a DOM: pi ships no HTML parser and the output is prose
6
+ * for a model, not a rendering. Every pattern bounds its tag matches with
7
+ * [^<>]* so a failed match stops at the next tag instead of rescanning to the
8
+ * end of input, keeping the pass linear on hostile pages.
9
+ */
10
+
11
+ const NAMED_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
12
+
13
+ function decodeAllEntities(text: string): string {
14
+ return text.replace(/&(?:#x([0-9a-fA-F]+)|#(\d+)|(amp|lt|gt|quot|apos|nbsp));/g, (token, hex?: string, dec?: string, named?: string) => {
15
+ if (named) return NAMED_ENTITIES[named] ?? token
16
+ const code = hex ? Number.parseInt(hex, 16) : Number(dec)
17
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : token
18
+ })
19
+ }
20
+
21
+ const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
22
+
23
+ export function htmlToMarkdown(html: string): string {
24
+ // Pre blocks are lifted out first so no later transform touches their content.
25
+ const preBodies: string[] = []
26
+ let work = html
27
+ .replace(/<!--[\s\S]*?-->/g, ' ')
28
+ .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
29
+ .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
30
+ preBodies.push(decodeAllEntities(stripInnerTags(inner)).replace(/^\n+|\n+$/g, ''))
31
+ return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
32
+ })
33
+
34
+ work = work
35
+ .replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${stripInnerTags(inner)}\``)
36
+ // Only real web links become markdown links; fragment and javascript hrefs
37
+ // keep their label and lose the target.
38
+ .replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
39
+ const href = decodeAllEntities(dq ?? sq ?? '')
40
+ const label = stripInnerTags(inner).trim()
41
+ if (!label) return ' '
42
+ return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
43
+ })
44
+ .replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${stripInnerTags(inner).trim()}**`)
45
+ .replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${stripInnerTags(inner).trim()}*`)
46
+ .replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${stripInnerTags(inner).trim()}\n\n`)
47
+ .replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
48
+ .replace(/<li\b[^<>]*>/gi, '\n- ')
49
+ .replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
50
+ .replace(/<\/(?:td|th)>/gi, ' | ')
51
+ .replace(/<(?:br|hr)\b[^<>]*>/gi, '\n')
52
+ .replace(/<\/(?:p|div|section|article|ul|ol|li|table|tr|blockquote|tbody|thead|header|footer|main|nav)[^<>]*>/gi, '\n\n')
53
+
54
+ const text = decodeAllEntities(work.replace(/<[^<>]*>/g, ''))
55
+ .replace(/[ \t]+/g, ' ')
56
+ .replace(/ ?\n ?/g, '\n')
57
+ .replace(/\n{3,}/g, '\n\n')
58
+ .trim()
59
+
60
+ return text.replace(/\uE000PRE(\d+)\uE000/g, (_whole, index: string) => `\`\`\`\n${preBodies[Number(index)]}\n\`\`\``)
61
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Channel and payload for instruction-file loads published on pi's shared extension
3
+ * event bus. Producers are claude-rules (a scoped rule lazily attaching on a matching
4
+ * file touch, load_reason `path_glob_match`) and context-imports (resolved `@imports`,
5
+ * load_reason `include`, and CLAUDE.local.md loads, load_reason `session_start`).
6
+ * Hooks bridge them to Claude's InstructionsLoaded event, which is strictly
7
+ * observational. pi loads extensions without a shared module cache, so state rides
8
+ * the bus, mirroring subagent-events.
9
+ */
10
+
11
+ import * as path from 'node:path'
12
+
13
+ export const INSTRUCTIONS_CHANNEL = 'pi-code:instructions'
14
+
15
+ /** Claude's memory_type vocabulary for InstructionsLoaded payloads. */
16
+ export type InstructionMemoryType = 'User' | 'Project' | 'Local' | 'Managed'
17
+
18
+ const MEMORY_TYPES: readonly string[] = ['User', 'Project', 'Local', 'Managed']
19
+
20
+ export interface InstructionLoadEvent {
21
+ /** Absolute path of the instruction file that entered context. */
22
+ file_path: string
23
+ memory_type: InstructionMemoryType
24
+ /** What caused the load; InstructionsLoaded matchers run against this. */
25
+ load_reason: string
26
+ /** The rule's `paths:` globs; present only for path_glob_match. */
27
+ globs?: string[]
28
+ /** The file whose access triggered a lazy load. */
29
+ trigger_file_path?: string
30
+ /** The importing file, for include loads. */
31
+ parent_file_path?: string
32
+ }
33
+
34
+ export function isInstructionLoadEvent(data: unknown): data is InstructionLoadEvent {
35
+ const event = data as InstructionLoadEvent | null
36
+ if (event === null || typeof event !== 'object') return false
37
+ if (typeof event.file_path !== 'string' || typeof event.load_reason !== 'string') return false
38
+ if (!MEMORY_TYPES.includes(event.memory_type)) return false
39
+ if (event.globs !== undefined && !(Array.isArray(event.globs) && event.globs.every((glob) => typeof glob === 'string'))) return false
40
+ if (event.trigger_file_path !== undefined && typeof event.trigger_file_path !== 'string') return false
41
+ if (event.parent_file_path !== undefined && typeof event.parent_file_path !== 'string') return false
42
+ return true
43
+ }
44
+
45
+ /** Claude's memory_type from a file's location: CLAUDE.local.md is Local wherever it
46
+ * sits; a file under home but outside the project is User; everything else, the
47
+ * project itself included (which commonly lives under home), is Project. */
48
+ export function memoryTypeForPath(filePath: string, home: string, projectRoot: string): InstructionMemoryType {
49
+ if (path.basename(filePath) === 'CLAUDE.local.md') return 'Local'
50
+ const isUnder = (root: string): boolean => root.length > 0 && (filePath === root || filePath.startsWith(root + path.sep))
51
+ if (isUnder(projectRoot)) return 'Project'
52
+ // Monorepo: repoRoot stops at the nearest .git OR package.json, so a git-root
53
+ // CLAUDE.md can sit above the projectRoot a subpackage session reports. A file
54
+ // whose directory is a strict ancestor of the project root is still project
55
+ // memory. The home directory itself stays User: a home-level context file is
56
+ // user config even when the project lives under home.
57
+ const dir = path.dirname(filePath)
58
+ if (dir !== home && (projectRoot === dir || projectRoot.startsWith(dir + path.sep))) return 'Project'
59
+ if (isUnder(home)) return 'User'
60
+ return 'Project'
61
+ }
62
+
63
+ /** The emit half of pi's EventBus; producers may run under stub hosts without one. */
64
+ export interface InstructionBus {
65
+ emit(channel: string, data: unknown): void
66
+ }
67
+
68
+ export function publishInstructionLoad(events: InstructionBus | undefined, event: InstructionLoadEvent): void {
69
+ events?.emit(INSTRUCTIONS_CHANNEL, event)
70
+ }