pi-code 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/git-checkpoint.ts +40 -0
- package/extensions/hooks.ts +203 -36
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +44 -8
- package/extensions/internal/path-rules.ts +69 -2
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +462 -84
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/background.ts +11 -0
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
package/extensions/hooks.ts
CHANGED
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
*
|
|
44
44
|
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
45
45
|
* .claude/settings.json and settings.local.json (only when the project is
|
|
46
|
-
* trusted, since hooks execute arbitrary shell).
|
|
46
|
+
* trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
|
|
47
|
+
* setting (managed settings or any honored file in that chain) short-circuits
|
|
48
|
+
* the load entirely, so no event fires any hook; /hooks prints the resolved
|
|
49
|
+
* chain per event with each entry's source settings file. Matchers follow Claude's rule:
|
|
47
50
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
48
51
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
49
52
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -60,6 +63,7 @@ import type { Api, Model } from '@earendil-works/pi-ai'
|
|
|
60
63
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
61
64
|
import { runAgent } from './internal/agent-run.js'
|
|
62
65
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
66
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
63
67
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
64
68
|
import { callMcpTool } from './internal/mcp-call.js'
|
|
65
69
|
import { completeText } from './internal/model-complete.js'
|
|
@@ -94,7 +98,7 @@ interface HookCommand {
|
|
|
94
98
|
model?: string
|
|
95
99
|
systemPrompt?: string
|
|
96
100
|
}
|
|
97
|
-
interface HookMatcher {
|
|
101
|
+
export interface HookMatcher {
|
|
98
102
|
matcher?: string
|
|
99
103
|
hooks: HookCommand[]
|
|
100
104
|
}
|
|
@@ -133,7 +137,62 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
|
|
|
133
137
|
return files
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
/** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
|
|
141
|
+
* hook misbehaves, so it is honored before any hook runs. Disabled when managed
|
|
142
|
+
* settings or ANY file in the settings chain sets it to `true`; deliberately not
|
|
143
|
+
* last-file-wins, since a repository file re-enabling the hooks the user just
|
|
144
|
+
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
145
|
+
* already gates project files on trust (see hookFiles). */
|
|
146
|
+
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
147
|
+
if (managed.disableAllHooks === true) return true
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
151
|
+
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
152
|
+
} catch {
|
|
153
|
+
// missing or invalid file: skip
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
160
|
+
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
161
|
+
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
162
|
+
* across settings sources. Merging is a union of managed settings plus every file in
|
|
163
|
+
* the chain; the chain already gates project files on trust (see hookFiles), and a
|
|
164
|
+
* trusted project can run arbitrary shell hooks anyway, so letting it extend the
|
|
165
|
+
* allowlist is no escalation. */
|
|
166
|
+
export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
|
|
167
|
+
let found: string[] | undefined
|
|
168
|
+
const collect = (value: unknown): void => {
|
|
169
|
+
if (!Array.isArray(value)) return
|
|
170
|
+
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
171
|
+
}
|
|
172
|
+
collect(managed.allowedHttpHookUrls)
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
176
|
+
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
177
|
+
} catch {
|
|
178
|
+
// missing or invalid file: skip
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return found
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
|
|
185
|
+
* of characters; everything else is literal and the whole URL must match. An
|
|
186
|
+
* undefined allowlist means the setting is absent, so there are no restrictions. */
|
|
187
|
+
export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
|
|
188
|
+
if (allowlist === undefined) return true
|
|
189
|
+
return allowlist.some((pattern) => {
|
|
190
|
+
const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
191
|
+
return new RegExp(`^${literal.join('.*')}$`).test(url)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
137
196
|
const config: HooksConfig = {}
|
|
138
197
|
for (const file of files) {
|
|
139
198
|
let raw: string
|
|
@@ -142,12 +201,12 @@ export function loadHooks(files: string[]): HooksConfig {
|
|
|
142
201
|
} catch {
|
|
143
202
|
continue
|
|
144
203
|
}
|
|
145
|
-
mergeHooksJson(config, raw, file)
|
|
204
|
+
mergeHooksJson(config, raw, file, sources)
|
|
146
205
|
}
|
|
147
206
|
return config
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
function mergeHooksJson(config: HooksConfig, raw: string, source: string): void {
|
|
209
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
|
|
151
210
|
let parsed: { hooks?: HooksConfig }
|
|
152
211
|
try {
|
|
153
212
|
parsed = JSON.parse(raw)
|
|
@@ -161,26 +220,30 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string): void
|
|
|
161
220
|
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
162
221
|
// call for the rest of the session failed with an opaque type error.
|
|
163
222
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
164
|
-
if (usable.length
|
|
223
|
+
if (usable.length === 0) continue
|
|
224
|
+
config[event] = [...(config[event] ?? []), ...usable]
|
|
225
|
+
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
226
|
+
// viewer's source attribution without touching the entries themselves.
|
|
227
|
+
for (const entry of usable) sources?.set(entry, source)
|
|
165
228
|
}
|
|
166
229
|
}
|
|
167
230
|
|
|
168
231
|
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
169
232
|
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
170
233
|
* hook can name its bundled scripts by real path. */
|
|
171
|
-
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[]): void {
|
|
234
|
+
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
172
235
|
for (const plugin of plugins) {
|
|
173
236
|
const declared = plugin.manifest.hooks
|
|
174
237
|
// An inline hooks object; an array is not a valid hooks map (it would parse to
|
|
175
238
|
// numeric event keys), so it falls through to the default path rather than
|
|
176
239
|
// silently registering nothing.
|
|
177
240
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
178
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)
|
|
241
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
179
242
|
continue
|
|
180
243
|
}
|
|
181
244
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
182
245
|
try {
|
|
183
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file)
|
|
246
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
184
247
|
} catch {
|
|
185
248
|
// a plugin without hooks contributes nothing
|
|
186
249
|
}
|
|
@@ -249,6 +312,23 @@ function isRunnableHook(hook: HookCommand): boolean {
|
|
|
249
312
|
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
250
313
|
}
|
|
251
314
|
|
|
315
|
+
/** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
|
|
316
|
+
* entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
|
|
317
|
+
* (undefined or `command` type) already has one, so this is undefined. */
|
|
318
|
+
function syntheticCommand(hook: HookCommand): string | undefined {
|
|
319
|
+
if (hook.type === 'http') return hook.url
|
|
320
|
+
if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
|
|
321
|
+
if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
|
|
322
|
+
return undefined
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A matched entry with its `command` filled in: mirroring the synthetic identity into
|
|
326
|
+
* `command` keeps dedup, timeout messages and display working for non-shell hooks. */
|
|
327
|
+
function withCommand(raw: HookCommand): HookCommand {
|
|
328
|
+
const identity = syntheticCommand(raw)
|
|
329
|
+
return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
330
|
+
}
|
|
331
|
+
|
|
252
332
|
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
253
333
|
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
254
334
|
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
@@ -258,11 +338,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
258
338
|
for (const entry of matchers ?? []) {
|
|
259
339
|
if (!matcherApplies(entry.matcher, candidates)) continue
|
|
260
340
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
261
|
-
|
|
262
|
-
// url / prompt / server:tool. Mirroring it into `command` keeps dedup, timeout
|
|
263
|
-
// messages and display working.
|
|
264
|
-
const identity = raw.type === 'http' ? raw.url : raw.type === 'prompt' || raw.type === 'agent' ? raw.prompt : raw.type === 'mcp_tool' ? `${raw.server}:${raw.tool}` : undefined
|
|
265
|
-
const hook = identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
341
|
+
const hook = withCommand(raw)
|
|
266
342
|
// Claude runs a handler defined in more than one settings file once.
|
|
267
343
|
if (seen.has(hook.command)) continue
|
|
268
344
|
seen.add(hook.command)
|
|
@@ -272,6 +348,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
272
348
|
return result
|
|
273
349
|
}
|
|
274
350
|
|
|
351
|
+
/** A hook entry's display identity for the /hooks viewer: the command for shell
|
|
352
|
+
* hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
|
|
353
|
+
* is named rather than hidden, since a misconfigured entry is exactly what the
|
|
354
|
+
* viewer exists to surface. */
|
|
355
|
+
function hookIdentity(hook: HookCommand | null | undefined): string {
|
|
356
|
+
// A hand-edited settings file can leave a null (or otherwise empty) entry in a
|
|
357
|
+
// hooks array; name it rather than let it crash the viewer that exists to surface
|
|
358
|
+
// exactly this kind of misconfiguration.
|
|
359
|
+
const record: Partial<HookCommand> = hook ?? {}
|
|
360
|
+
const type = record.type ?? 'command'
|
|
361
|
+
if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
|
|
362
|
+
if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
|
|
363
|
+
if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
|
|
364
|
+
return `command: ${record.command ?? '(missing command)'}`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Render the resolved hooks config as a readable per-event summary for /hooks:
|
|
368
|
+
* one line per configured hook with its matcher, identity and, when known, the
|
|
369
|
+
* settings file it came from. Pure formatting of already-resolved data. */
|
|
370
|
+
export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
|
|
371
|
+
const lines: string[] = []
|
|
372
|
+
for (const [event, matchers] of Object.entries(config)) {
|
|
373
|
+
const entryLines: string[] = []
|
|
374
|
+
for (const entry of matchers) {
|
|
375
|
+
const matcher = entry.matcher || '*'
|
|
376
|
+
const source = sources?.get(entry)
|
|
377
|
+
const suffix = source ? ` (${source})` : ''
|
|
378
|
+
for (const hook of entry.hooks ?? []) {
|
|
379
|
+
entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
|
|
383
|
+
}
|
|
384
|
+
if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
|
|
385
|
+
return lines.join('\n')
|
|
386
|
+
}
|
|
387
|
+
|
|
275
388
|
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
276
389
|
try {
|
|
277
390
|
return JSON.parse(text)
|
|
@@ -369,7 +482,7 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
|
|
|
369
482
|
const allowedSet = new Set(allowed ?? [])
|
|
370
483
|
const out: Record<string, string> = {}
|
|
371
484
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
372
|
-
out[key] = value.replace(/\$(?:\{([A-Za-z_]
|
|
485
|
+
out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
|
|
373
486
|
const name = braced ?? bare ?? ''
|
|
374
487
|
return allowedSet.has(name) ? (process.env[name] ?? '') : ''
|
|
375
488
|
})
|
|
@@ -382,12 +495,15 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
|
|
|
382
495
|
* with a valid JSON body renders a decision, read exactly like command stdout.
|
|
383
496
|
* Everything else, including non-2xx statuses, connection failures and timeouts,
|
|
384
497
|
* is a non-blocking error by contract, so none of these outcomes ever reports
|
|
385
|
-
* `timedOut`, which PreToolUse fails closed on.
|
|
386
|
-
*
|
|
387
|
-
*
|
|
498
|
+
* `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
|
|
499
|
+
* allowlist gates the fetch itself: a URL matching no entry is never contacted,
|
|
500
|
+
* so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
|
|
501
|
+
* the payload; when the setting is absent there are no restrictions, as Claude
|
|
502
|
+
* documents. A blocked hook renders no decision, like every other http failure.
|
|
388
503
|
*/
|
|
389
|
-
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
504
|
+
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
|
|
390
505
|
const url = hook.url ?? hook.command
|
|
506
|
+
if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
|
|
391
507
|
try {
|
|
392
508
|
const response = await fetch(url, {
|
|
393
509
|
method: 'POST',
|
|
@@ -538,8 +654,9 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
|
|
|
538
654
|
Object.assign(target, next)
|
|
539
655
|
}
|
|
540
656
|
|
|
541
|
-
/** Claude surfaces a hook error notice
|
|
542
|
-
*
|
|
657
|
+
/** Claude surfaces a hook error notice; on ungated events the action proceeds, while
|
|
658
|
+
* PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
|
|
659
|
+
* their spawnFailed checks). Silence would hide that a guard never ran. */
|
|
543
660
|
function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
|
|
544
661
|
if (!notify) return
|
|
545
662
|
for (const [i, result] of results.entries()) {
|
|
@@ -571,6 +688,10 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
|
|
|
571
688
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
572
689
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
573
690
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
691
|
+
// A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
|
|
692
|
+
// its code 0 must fail closed like a timeout, not read as an allow exactly when
|
|
693
|
+
// the machine is degraded.
|
|
694
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
|
|
574
695
|
}
|
|
575
696
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
576
697
|
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
@@ -621,6 +742,8 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
621
742
|
surfaceHookFailures(commands, results, onSystemMessage)
|
|
622
743
|
for (const [i, result] of results.entries()) {
|
|
623
744
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
745
|
+
// No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
|
|
746
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
|
|
624
747
|
}
|
|
625
748
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
626
749
|
const contexts: string[] = []
|
|
@@ -646,12 +769,34 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
646
769
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
647
770
|
}
|
|
648
771
|
|
|
772
|
+
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
773
|
+
* tool result: a block notice (exit-2 stderr, or decision:block on success) followed
|
|
774
|
+
* by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
|
|
775
|
+
* but never a decision:block verdict. */
|
|
776
|
+
function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
|
|
777
|
+
const lines: string[] = []
|
|
778
|
+
const parsed = tryParseJson(result.stdout)
|
|
779
|
+
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
780
|
+
// success, exit-2 / decision:block feed back as a block notice.
|
|
781
|
+
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
782
|
+
else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
783
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
784
|
+
if (context) lines.push(context)
|
|
785
|
+
return lines
|
|
786
|
+
}
|
|
787
|
+
|
|
649
788
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
650
789
|
let config: HooksConfig = {}
|
|
651
790
|
let projectDir = ''
|
|
791
|
+
/** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
|
|
792
|
+
let allowedHttpHookUrls: string[] | undefined
|
|
652
793
|
let pendingSessionContext: string[] = []
|
|
653
794
|
let stopHookActive = false
|
|
654
795
|
let sessionCtx: ExtensionContext | undefined
|
|
796
|
+
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
797
|
+
let hooksDisabled = false
|
|
798
|
+
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
799
|
+
const hookSources = new Map<HookMatcher, string>()
|
|
655
800
|
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
656
801
|
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
657
802
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
@@ -666,7 +811,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
666
811
|
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
667
812
|
(hook, payload, ms) => {
|
|
668
813
|
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
669
|
-
if (hook.type === 'http') return runHttpHook(hook, merged, ms)
|
|
814
|
+
if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
|
|
670
815
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
671
816
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
672
817
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
@@ -717,8 +862,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
717
862
|
const ctx = sessionCtx
|
|
718
863
|
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
719
864
|
const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
|
|
720
|
-
|
|
721
|
-
|
|
865
|
+
try {
|
|
866
|
+
const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
|
|
867
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
868
|
+
} catch {
|
|
869
|
+
// The bus outlives the session: an event landing between /new disposing this
|
|
870
|
+
// ctx and the next session_start hits disposed getters, and nothing awaits a
|
|
871
|
+
// bus listener, so a throw here would escape as an unhandled rejection.
|
|
872
|
+
}
|
|
722
873
|
})
|
|
723
874
|
|
|
724
875
|
pi.on('session_start', async (event, ctx) => {
|
|
@@ -728,10 +879,21 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
728
879
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
729
880
|
// subdirectory session too.
|
|
730
881
|
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
731
|
-
|
|
882
|
+
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
883
|
+
hookSources.clear()
|
|
884
|
+
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
885
|
+
// The disableAllHooks escape hatch, checked before any config loads: with no
|
|
886
|
+
// config resolved, no event, plugin hooks included, can fire a hook.
|
|
887
|
+
hooksDisabled = readDisableAllHooks(files)
|
|
888
|
+
if (hooksDisabled) {
|
|
889
|
+
config = {}
|
|
890
|
+
pendingSessionContext = []
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
config = loadHooks(files, hookSources)
|
|
732
894
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
733
895
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
734
|
-
loadPluginHooks(config, installedPlugins(os.homedir()))
|
|
896
|
+
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
735
897
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
736
898
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
737
899
|
if (event.reason === 'reload') return
|
|
@@ -784,16 +946,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
784
946
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
785
947
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
786
948
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
787
|
-
const feedback
|
|
788
|
-
for (const result of results) {
|
|
789
|
-
const parsed = tryParseJson(result.stdout)
|
|
790
|
-
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
791
|
-
// success, exit-2 / decision:block feed back as a block notice.
|
|
792
|
-
if (!result.timedOut && result.code === 2) feedback.push(`${eventName} hook: ${result.stderr.trim() || (event.isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
793
|
-
else if (!event.isError && parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
794
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
795
|
-
if (context) feedback.push(context)
|
|
796
|
-
}
|
|
949
|
+
const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
|
|
797
950
|
if (feedback.length === 0) return
|
|
798
951
|
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
799
952
|
})
|
|
@@ -869,4 +1022,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
869
1022
|
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
870
1023
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
871
1024
|
})
|
|
1025
|
+
|
|
1026
|
+
// Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
|
|
1027
|
+
// are otherwise opaque, so showing the resolved chain per event, with the settings
|
|
1028
|
+
// file each entry came from, is the debugging surface.
|
|
1029
|
+
pi.registerCommand('hooks', {
|
|
1030
|
+
description: 'Show the hook configuration resolved from settings',
|
|
1031
|
+
handler: async (_args, ctx) => {
|
|
1032
|
+
if (hooksDisabled) {
|
|
1033
|
+
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
|
|
1037
|
+
},
|
|
1038
|
+
})
|
|
872
1039
|
}
|
|
@@ -16,6 +16,9 @@ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
|
16
16
|
|
|
17
17
|
import { splitSegments } from './shell-split.js'
|
|
18
18
|
|
|
19
|
+
/** The pi file tools a Claude path rule can govern. */
|
|
20
|
+
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
21
|
+
|
|
19
22
|
export interface ParsedCommand {
|
|
20
23
|
description: string
|
|
21
24
|
argumentHint?: string
|
|
@@ -23,7 +26,7 @@ export interface ParsedCommand {
|
|
|
23
26
|
/** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
|
|
24
27
|
bashRules?: string[]
|
|
25
28
|
/** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
|
|
26
|
-
pathRules?: Partial<Record<
|
|
29
|
+
pathRules?: Partial<Record<PathRuleTool, string[]>>
|
|
27
30
|
/** Names from the `arguments:` frontmatter list, mapped to positions in order. */
|
|
28
31
|
argumentNames?: string[]
|
|
29
32
|
/** Tools removed from the pool while the command's turn runs. */
|
|
@@ -66,6 +69,10 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
|
66
69
|
task: 'subagent',
|
|
67
70
|
askuserquestion: 'question',
|
|
68
71
|
exitplanmode: 'plan_mode_complete',
|
|
72
|
+
// Claude's name for the tool this package registers so the model can run user slash
|
|
73
|
+
// commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
|
|
74
|
+
// could neither keep nor drop the tool.
|
|
75
|
+
slashcommand: 'slash_command',
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -118,18 +125,78 @@ export interface ToolGrants {
|
|
|
118
125
|
/** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
|
|
119
126
|
* Edit scopes govern writes too, as Claude documents; Write scopes are honored
|
|
120
127
|
* rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
|
|
121
|
-
pathRules?: Partial<Record<
|
|
128
|
+
pathRules?: Partial<Record<PathRuleTool, string[]>>
|
|
122
129
|
/** Entries that carried an argument scope, in their original spelling. */
|
|
123
130
|
scopedEntries: string[]
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
/** The pi file tools one Claude path-ruled entry governs. */
|
|
127
|
-
const PATH_RULE_TOOLS: Record<string, Array<
|
|
134
|
+
const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
|
|
128
135
|
read: ['read'],
|
|
129
136
|
edit: ['edit', 'write'],
|
|
130
137
|
write: ['write'],
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
/** The tools, scopes, and path rules accumulated while scanning one grant list. */
|
|
141
|
+
interface GrantAccumulator {
|
|
142
|
+
tools: string[]
|
|
143
|
+
scopedEntries: string[]
|
|
144
|
+
bashRules: string[]
|
|
145
|
+
bashUnscoped: boolean
|
|
146
|
+
pathScopes: Record<PathRuleTool, string[]>
|
|
147
|
+
pathUnscoped: Set<PathRuleTool>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createGrantAccumulator(): GrantAccumulator {
|
|
151
|
+
return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Coerce a raw grant value to its string entries: a YAML list stays a list, a
|
|
155
|
+
* comma-separated string is split, and anything else (or a list with a non-string
|
|
156
|
+
* member) is rejected as undefined, the same "not a grant" signal as an absent field. */
|
|
157
|
+
function coerceGrantItems(raw: unknown): string[] | undefined {
|
|
158
|
+
let items: unknown[]
|
|
159
|
+
if (Array.isArray(raw)) items = raw
|
|
160
|
+
else if (typeof raw === 'string') items = toolEntries(raw)
|
|
161
|
+
else return undefined
|
|
162
|
+
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
163
|
+
return items as string[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Fold one grant entry into the accumulator: the base tool is granted, an unscoped
|
|
167
|
+
* entry marks its tools wide, and a scoped entry records the scope for bash and the
|
|
168
|
+
* file tools it governs. */
|
|
169
|
+
function addGrantEntry(acc: GrantAccumulator, item: string): void {
|
|
170
|
+
const entry = item.trim()
|
|
171
|
+
const name = normalizeToolName(entry)
|
|
172
|
+
if (!name) return
|
|
173
|
+
if (!acc.tools.includes(name)) acc.tools.push(name)
|
|
174
|
+
const open = entry.indexOf('(')
|
|
175
|
+
if (open === -1) {
|
|
176
|
+
if (name === 'bash') acc.bashUnscoped = true
|
|
177
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
acc.scopedEntries.push(entry)
|
|
181
|
+
const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
|
|
182
|
+
// An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
|
|
183
|
+
// the unscoped grant it explicitly is not: it is recorded so the tool stays
|
|
184
|
+
// restricted, and the matchers treat an empty rule as matching no input.
|
|
185
|
+
if (name === 'bash') acc.bashRules.push(scope)
|
|
186
|
+
for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The per-tool path rules from a scan: a tool with any unscoped grant is omitted
|
|
190
|
+
* (it is wide), one with only scoped grants keeps them, and the whole map is absent
|
|
191
|
+
* when no tool carries a rule. */
|
|
192
|
+
function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
|
|
193
|
+
const pathRules: NonNullable<ToolGrants['pathRules']> = {}
|
|
194
|
+
for (const tool of ['read', 'edit', 'write'] as const) {
|
|
195
|
+
if (!acc.pathUnscoped.has(tool) && acc.pathScopes[tool].length > 0) pathRules[tool] = acc.pathScopes[tool]
|
|
196
|
+
}
|
|
197
|
+
return Object.keys(pathRules).length > 0 ? pathRules : undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
133
200
|
/**
|
|
134
201
|
* A tool grant is either a comma-separated string or a YAML list, and the two mean the
|
|
135
202
|
* same thing. An empty list is not the same as an absent one: it says no tools, so it
|
|
@@ -143,47 +210,15 @@ const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
|
|
|
143
210
|
* whose widening reaches everything, so it is the one enforced.
|
|
144
211
|
*/
|
|
145
212
|
export function parseToolGrants(raw: unknown): ToolGrants | undefined {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
else return undefined
|
|
151
|
-
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
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
|
-
}
|
|
213
|
+
const items = coerceGrantItems(raw)
|
|
214
|
+
if (items === undefined) return undefined
|
|
215
|
+
const acc = createGrantAccumulator()
|
|
216
|
+
for (const item of items) addGrantEntry(acc, item)
|
|
182
217
|
return {
|
|
183
|
-
tools,
|
|
184
|
-
scopedEntries,
|
|
185
|
-
bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
|
|
186
|
-
pathRules:
|
|
218
|
+
tools: acc.tools,
|
|
219
|
+
scopedEntries: acc.scopedEntries,
|
|
220
|
+
bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
|
|
221
|
+
pathRules: buildPathRules(acc),
|
|
187
222
|
}
|
|
188
223
|
}
|
|
189
224
|
|
|
@@ -193,17 +228,25 @@ const text = (value: unknown): string => {
|
|
|
193
228
|
return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
|
|
194
229
|
}
|
|
195
230
|
|
|
231
|
+
/** YAML's affirmative boolean spellings. Claude documents `disable-model-invocation:
|
|
232
|
+
* true`, but a command file is hand-written YAML where `yes`, `on`, and `1` are all
|
|
233
|
+
* ordinary spellings of true, and pi's parser hands those back as the raw string or
|
|
234
|
+
* number rather than a boolean. A flag that gates a command off from the model has to
|
|
235
|
+
* honor them, or a command the user marked off-limits is silently offered to it. */
|
|
236
|
+
const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
|
|
237
|
+
const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
|
|
238
|
+
|
|
196
239
|
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
197
240
|
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
198
241
|
|
|
199
|
-
const ARGUMENT_NAME = /^[A-Za-z_]
|
|
242
|
+
const ARGUMENT_NAME = /^[A-Za-z_]\w*$/
|
|
200
243
|
|
|
201
244
|
/** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
|
|
202
245
|
* of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
|
|
203
246
|
* itself is reserved by the built-in placeholder. */
|
|
204
247
|
function parseArgumentNames(raw: unknown): string[] | undefined {
|
|
205
248
|
let items: string[]
|
|
206
|
-
if (Array.isArray(raw)) items = raw.map(
|
|
249
|
+
if (Array.isArray(raw)) items = raw.map(String)
|
|
207
250
|
else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
|
|
208
251
|
else return undefined
|
|
209
252
|
const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
|
|
@@ -233,7 +276,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
233
276
|
disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
|
|
234
277
|
shell: SHELLS.has(shell) ? shell : undefined,
|
|
235
278
|
model: text(frontmatter.model) || undefined,
|
|
236
|
-
disableModelInvocation:
|
|
279
|
+
disableModelInvocation: isFlagEnabled(disable),
|
|
237
280
|
body,
|
|
238
281
|
}
|
|
239
282
|
}
|
|
@@ -300,8 +343,8 @@ export function substituteArgsDetailed(body: string, args: string, names: string
|
|
|
300
343
|
return value
|
|
301
344
|
}
|
|
302
345
|
const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
|
|
303
|
-
if (token ===
|
|
304
|
-
if (token ===
|
|
346
|
+
if (token === String.raw`\\`) return token
|
|
347
|
+
if (token === String.raw`\$`) return '$'
|
|
305
348
|
if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
|
|
306
349
|
if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
|
|
307
350
|
if (argsDefault !== undefined) {
|
|
@@ -20,6 +20,16 @@ function decodeAllEntities(text: string): string {
|
|
|
20
20
|
|
|
21
21
|
const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
|
|
22
22
|
|
|
23
|
+
// Strip leading and trailing newline runs in linear time. The equivalent
|
|
24
|
+
// /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
|
|
25
|
+
const trimNewlines = (value: string): string => {
|
|
26
|
+
let start = 0
|
|
27
|
+
let end = value.length
|
|
28
|
+
while (start < end && value[start] === '\n') start++
|
|
29
|
+
while (end > start && value[end - 1] === '\n') end--
|
|
30
|
+
return value.slice(start, end)
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
export function htmlToMarkdown(html: string): string {
|
|
24
34
|
// Pre blocks are lifted out first so no later transform touches their content.
|
|
25
35
|
const preBodies: string[] = []
|
|
@@ -27,7 +37,7 @@ export function htmlToMarkdown(html: string): string {
|
|
|
27
37
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
28
38
|
.replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
|
|
29
39
|
.replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
|
|
30
|
-
preBodies.push(decodeAllEntities(stripInnerTags(inner))
|
|
40
|
+
preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
|
|
31
41
|
return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
|
|
32
42
|
})
|
|
33
43
|
|