pi-code 1.0.13 → 1.0.15
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/README.md +1 -1
- package/extensions/commands.ts +27 -30
- package/extensions/context-imports.ts +6 -14
- package/extensions/hooks/config.ts +234 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +497 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +269 -0
- package/extensions/internal/command-file.ts +1 -1
- package/extensions/internal/managed-settings.ts +5 -0
- package/extensions/internal/output-guard.ts +1 -1
- package/extensions/internal/settings-chain.ts +24 -0
- package/extensions/internal/stat-token.ts +14 -0
- package/extensions/internal/turn-override.ts +73 -0
- package/extensions/mcp/config.ts +159 -0
- package/extensions/mcp/index.ts +513 -0
- package/extensions/mcp/listing.ts +92 -0
- package/extensions/mcp/mapping.ts +173 -0
- package/extensions/mcp/oauth-flow.ts +80 -0
- package/extensions/mcp/policy.ts +140 -0
- package/extensions/mcp/transport.ts +258 -0
- package/extensions/memory.ts +6 -10
- package/extensions/output-styles.ts +2 -6
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/question.ts +2 -2
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/index.ts +6 -123
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +25 -31
- package/package.json +1 -1
- package/extensions/hooks.ts +0 -1179
- package/extensions/mcp.ts +0 -1358
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
45
45
|
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
|
|
46
46
|
| `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
|
|
47
47
|
| Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
48
|
-
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
48
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
49
49
|
| Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
50
50
|
| CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
|
|
51
51
|
| Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
|
package/extensions/commands.ts
CHANGED
|
@@ -51,7 +51,9 @@ import { capForContext } from './internal/output-guard.js'
|
|
|
51
51
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
52
52
|
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
53
53
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
54
|
-
import { findNearestDir,
|
|
54
|
+
import { findNearestDir, repoRoot } from './internal/project-root.js'
|
|
55
|
+
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
56
|
+
import { createTurnOverride } from './internal/turn-override.js'
|
|
55
57
|
|
|
56
58
|
type PathRuleTool = 'read' | 'edit' | 'write'
|
|
57
59
|
|
|
@@ -136,7 +138,7 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
|
|
|
136
138
|
|
|
137
139
|
/** A plugin's command files, namespaced `plugin:name` as Claude registers them.
|
|
138
140
|
* The manifest may point `commands` somewhere else; the default is `commands/`. */
|
|
139
|
-
|
|
141
|
+
function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
|
|
140
142
|
const found: DiscoveredCommand[] = []
|
|
141
143
|
for (const plugin of plugins) {
|
|
142
144
|
const declared = plugin.manifest.commands
|
|
@@ -166,12 +168,7 @@ type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
|
|
|
166
168
|
*/
|
|
167
169
|
export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
|
|
168
170
|
if (readManagedSettings().disableSkillShellExecution === true) return true
|
|
169
|
-
const files =
|
|
170
|
-
if (trusted) {
|
|
171
|
-
for (const name of ['settings.json', 'settings.local.json']) {
|
|
172
|
-
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
173
|
-
}
|
|
174
|
-
}
|
|
171
|
+
const files = claudeSettingsChain(cwd, home, trusted)
|
|
175
172
|
return files.some((file) => {
|
|
176
173
|
try {
|
|
177
174
|
return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
|
|
@@ -307,10 +304,21 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
307
304
|
let pendingBashRules: string[] | undefined
|
|
308
305
|
/** Read/Edit path scopes enforced the same way, per pi file tool. */
|
|
309
306
|
let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
|
|
310
|
-
/** The session model to restore after a command's `model:` override drove its run
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
307
|
+
/** The session model to restore after a command's `model:` override drove its run,
|
|
308
|
+
* captured once per turn so a second command restores the original session model. */
|
|
309
|
+
const modelOverride = createTurnOverride<ModelLike>({
|
|
310
|
+
// setModel can reject (e.g. auth resolution fails), and a floated rejection would
|
|
311
|
+
// escape as unhandled; surface it as a no-op instead of leaving the session silently
|
|
312
|
+
// on the command's override model.
|
|
313
|
+
set: (model) => {
|
|
314
|
+
void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch(() => {})
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
/** The thinking level to restore after a command's `effort:` override drove its run,
|
|
318
|
+
* captured once per turn the same way. */
|
|
319
|
+
const effortOverride = createTurnOverride<string>({
|
|
320
|
+
set: (level) => pi.setThinkingLevel(level as Parameters<typeof pi.setThinkingLevel>[0]),
|
|
321
|
+
})
|
|
314
322
|
|
|
315
323
|
// Claude's contract is "the grant clears when you send your next message", and
|
|
316
324
|
// pi's turn_end fires after every assistant step: restoring there stripped a
|
|
@@ -323,19 +331,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
323
331
|
pi.on('agent_settled', async () => {
|
|
324
332
|
pendingBashRules = undefined
|
|
325
333
|
pendingPathRules = undefined
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
pendingModelRestore = undefined
|
|
329
|
-
// setModel can reject (e.g. auth resolution fails), and a floated rejection would
|
|
330
|
-
// escape as unhandled; surface it instead of leaving the session silently on the
|
|
331
|
-
// command's override model.
|
|
332
|
-
void pi.setModel(restore).catch(() => {})
|
|
333
|
-
}
|
|
334
|
-
if (pendingEffortRestore) {
|
|
335
|
-
const level = pendingEffortRestore as Parameters<typeof pi.setThinkingLevel>[0]
|
|
336
|
-
pendingEffortRestore = undefined
|
|
337
|
-
pi.setThinkingLevel(level)
|
|
338
|
-
}
|
|
334
|
+
modelOverride.settle()
|
|
335
|
+
effortOverride.settle()
|
|
339
336
|
if (pendingRestore) {
|
|
340
337
|
pi.setActiveTools(pendingRestore)
|
|
341
338
|
pendingRestore = undefined
|
|
@@ -412,7 +409,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
412
409
|
async function applyModelOverride(parsed: ParsedCommand, varCtx: VarContext): Promise<void> {
|
|
413
410
|
const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
|
|
414
411
|
if (target && varCtx.model && target.id !== varCtx.model.id) {
|
|
415
|
-
|
|
412
|
+
modelOverride.arm(varCtx.model)
|
|
416
413
|
await pi.setModel(target as Parameters<typeof pi.setModel>[0])
|
|
417
414
|
}
|
|
418
415
|
}
|
|
@@ -421,11 +418,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
421
418
|
* the session level resumes; restore happens on agent_settled like the model restore.
|
|
422
419
|
* Applied before sendUserMessage so the run it drives happens at the new level. Only the
|
|
423
420
|
* first override in a turn records the restore target, so a second command restores to
|
|
424
|
-
* the original session level rather than the first command's override (as
|
|
421
|
+
* the original session level rather than the first command's override (as the model override). */
|
|
425
422
|
function applyEffortOverride(parsed: ParsedCommand, varCtx: VarContext): void {
|
|
426
423
|
const target = parsed.effort
|
|
427
424
|
if (target && varCtx.thinkingLevel && target !== varCtx.thinkingLevel) {
|
|
428
|
-
|
|
425
|
+
effortOverride.arm(varCtx.thinkingLevel)
|
|
429
426
|
pi.setThinkingLevel(target as Parameters<typeof pi.setThinkingLevel>[0])
|
|
430
427
|
}
|
|
431
428
|
}
|
|
@@ -474,8 +471,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
474
471
|
pendingRestore = undefined
|
|
475
472
|
pendingBashRules = undefined
|
|
476
473
|
pendingPathRules = undefined
|
|
477
|
-
|
|
478
|
-
|
|
474
|
+
modelOverride.reset()
|
|
475
|
+
effortOverride.reset()
|
|
479
476
|
const trusted = await isProjectApproved(ctx)
|
|
480
477
|
projectApproved = trusted
|
|
481
478
|
// A resume/fork/new session can switch projects in-process. pi cannot unregister a
|
|
@@ -75,6 +75,8 @@ import { managedSettingsPath, readManagedSettings } from './internal/managed-set
|
|
|
75
75
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
76
76
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
77
77
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
78
|
+
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
79
|
+
import { statToken } from './internal/stat-token.js'
|
|
78
80
|
import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
|
|
79
81
|
|
|
80
82
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
@@ -93,7 +95,7 @@ function isUnder(target: string, roots: string[]): boolean {
|
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
/** Realpath the roots that exist; used both to seed and to bound the import search. */
|
|
96
|
-
|
|
98
|
+
function realRoots(candidates: string[]): string[] {
|
|
97
99
|
const roots: string[] = []
|
|
98
100
|
for (const candidate of candidates) {
|
|
99
101
|
try {
|
|
@@ -249,7 +251,7 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
|
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
/** Claude's env gate for loading memory files from --add-dir directories. */
|
|
252
|
-
|
|
254
|
+
const ADDITIONAL_DIRS_ENV = 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'
|
|
253
255
|
|
|
254
256
|
/** Whether the env gate is on. Claude documents `=1`; any value that is not
|
|
255
257
|
* empty/0/false/no counts, so `=true` behaves as a user would expect. */
|
|
@@ -319,7 +321,7 @@ export function managedClaudeMdPath(): string {
|
|
|
319
321
|
}
|
|
320
322
|
|
|
321
323
|
/** The managed CLAUDE.md file body, or '' when absent or unreadable. */
|
|
322
|
-
|
|
324
|
+
function readManagedClaudeMdFile(): string {
|
|
323
325
|
try {
|
|
324
326
|
return fs.readFileSync(managedClaudeMdPath(), 'utf-8')
|
|
325
327
|
} catch {
|
|
@@ -375,12 +377,7 @@ function withTopBlock(prompt: string, block: string): string {
|
|
|
375
377
|
* settings.local.json (nearest at or above cwd) only when the project is
|
|
376
378
|
* approved. Managed settings are read separately by the caller. */
|
|
377
379
|
export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
378
|
-
|
|
379
|
-
if (!approved) return files
|
|
380
|
-
for (const name of ['settings.json', 'settings.local.json']) {
|
|
381
|
-
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
382
|
-
}
|
|
383
|
-
return files
|
|
380
|
+
return claudeSettingsChain(cwd, home, approved)
|
|
384
381
|
}
|
|
385
382
|
|
|
386
383
|
/** Merged `claudeMdExcludes` globs across the settings chain plus managed
|
|
@@ -641,11 +638,6 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
641
638
|
tokens: Array<[string, string]>
|
|
642
639
|
}
|
|
643
640
|
| undefined
|
|
644
|
-
// mtime plus size, so a same-mtime rewrite of a different length still invalidates.
|
|
645
|
-
const statToken = (file: string): string => {
|
|
646
|
-
const stat = fs.statSync(file)
|
|
647
|
-
return `${stat.mtimeMs}:${stat.size}`
|
|
648
|
-
}
|
|
649
641
|
const memoIsFresh = (memo: NonNullable<typeof importMemo>): boolean => {
|
|
650
642
|
try {
|
|
651
643
|
return memo.tokens.every(([file, token]) => statToken(file) === token)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook configuration: the settings-chain resolution, the hooks loaders (settings
|
|
3
|
+
* files and plugins), the disableAllHooks / allowedHttpHookUrls readers, and the
|
|
4
|
+
* /hooks viewer formatting. Pure config-shape types and loading, no execution.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs'
|
|
8
|
+
import * as path from 'node:path'
|
|
9
|
+
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
10
|
+
import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
|
|
11
|
+
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
12
|
+
|
|
13
|
+
export interface HookCommand {
|
|
14
|
+
type?: string
|
|
15
|
+
command: string
|
|
16
|
+
/** exec-form: spawn `command` directly with these args and no shell (shell-form when
|
|
17
|
+
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
18
|
+
args?: string[]
|
|
19
|
+
timeout?: number
|
|
20
|
+
/** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
|
|
21
|
+
* without blocking its event and with no timeout enforced; `asyncRewake` also runs in
|
|
22
|
+
* the background but keeps its timeout, and wakes the model when the hook exits 2
|
|
23
|
+
* (stderr, or stdout when stderr is empty, feeds back as a new turn). Background hooks
|
|
24
|
+
* render no decision; their JSON `systemMessage`/`additionalContext` reach the model on
|
|
25
|
+
* the next turn, and any still running are killed at session end. */
|
|
26
|
+
async?: boolean
|
|
27
|
+
asyncRewake?: boolean
|
|
28
|
+
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
29
|
+
url?: string
|
|
30
|
+
headers?: Record<string, string>
|
|
31
|
+
allowedEnvVars?: string[]
|
|
32
|
+
/** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
|
|
33
|
+
prompt?: string
|
|
34
|
+
/** mcp_tool entries: the connected server and tool to call, with optional input. */
|
|
35
|
+
server?: string
|
|
36
|
+
tool?: string
|
|
37
|
+
input?: Record<string, unknown>
|
|
38
|
+
/** prompt/agent entries: an optional model override; agent adds a system prompt. */
|
|
39
|
+
model?: string
|
|
40
|
+
systemPrompt?: string
|
|
41
|
+
}
|
|
42
|
+
export interface HookMatcher {
|
|
43
|
+
matcher?: string
|
|
44
|
+
hooks: HookCommand[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether a hook runs in the background. Claude documents `async`/`asyncRewake` on
|
|
48
|
+
* `type: "command"` hooks only; on any other type the fields are inert and the hook
|
|
49
|
+
* blocks, exactly as Claude runs it. */
|
|
50
|
+
export function isBackgroundHook(hook: HookCommand): boolean {
|
|
51
|
+
return (hook.type === undefined || hook.type === 'command') && (hook.async === true || hook.asyncRewake === true)
|
|
52
|
+
}
|
|
53
|
+
export type HooksConfig = Record<string, HookMatcher[]>
|
|
54
|
+
|
|
55
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
60
|
+
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
61
|
+
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
62
|
+
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
63
|
+
return claudeSettingsChain(cwd, home, trusted)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
|
|
67
|
+
* hook misbehaves, so it is honored before any hook runs. Disabled when managed
|
|
68
|
+
* settings or ANY file in the settings chain sets it to `true`; deliberately not
|
|
69
|
+
* last-file-wins, since a repository file re-enabling the hooks the user just
|
|
70
|
+
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
71
|
+
* already gates project files on trust (see hookFiles). */
|
|
72
|
+
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
73
|
+
if (managed.disableAllHooks === true) return true
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
77
|
+
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
78
|
+
} catch {
|
|
79
|
+
// missing or invalid file: skip
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
86
|
+
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
87
|
+
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
88
|
+
* across settings sources. Merging is a union of managed settings plus every file in
|
|
89
|
+
* the chain; the chain already gates project files on trust (see hookFiles), and a
|
|
90
|
+
* trusted project can run arbitrary shell hooks anyway, so letting it extend the
|
|
91
|
+
* allowlist is no escalation. */
|
|
92
|
+
export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
|
|
93
|
+
let found: string[] | undefined
|
|
94
|
+
const collect = (value: unknown): void => {
|
|
95
|
+
if (!Array.isArray(value)) return
|
|
96
|
+
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
97
|
+
}
|
|
98
|
+
collect(managed.allowedHttpHookUrls)
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
102
|
+
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
103
|
+
} catch {
|
|
104
|
+
// missing or invalid file: skip
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
|
|
111
|
+
* of characters; everything else is literal and the whole URL must match. An
|
|
112
|
+
* undefined allowlist means the setting is absent, so there are no restrictions. */
|
|
113
|
+
export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
|
|
114
|
+
if (allowlist === undefined) return true
|
|
115
|
+
return allowlist.some((pattern) => {
|
|
116
|
+
const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
117
|
+
return new RegExp(`^${literal.join('.*')}$`).test(url)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
122
|
+
const config: HooksConfig = {}
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
let raw: string
|
|
125
|
+
try {
|
|
126
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
127
|
+
} catch {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
mergeHooksJson(config, raw, file, sources)
|
|
131
|
+
}
|
|
132
|
+
return config
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
|
|
136
|
+
let parsed: { hooks?: HooksConfig }
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw)
|
|
139
|
+
} catch {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
143
|
+
if (!Array.isArray(matchers)) continue
|
|
144
|
+
// Entries are validated here rather than where they run: a hand-edited settings
|
|
145
|
+
// file that writes `hooks` as an object instead of a list used to throw out of
|
|
146
|
+
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
147
|
+
// call for the rest of the session failed with an opaque type error.
|
|
148
|
+
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
149
|
+
if (usable.length === 0) continue
|
|
150
|
+
config[event] = [...(config[event] ?? []), ...usable]
|
|
151
|
+
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
152
|
+
// viewer's source attribution without touching the entries themselves.
|
|
153
|
+
for (const entry of usable) sources?.set(entry, source)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
158
|
+
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
159
|
+
* hook can name its bundled scripts by real path. */
|
|
160
|
+
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
161
|
+
for (const plugin of plugins) {
|
|
162
|
+
const declared = plugin.manifest.hooks
|
|
163
|
+
// An inline hooks object; an array is not a valid hooks map (it would parse to
|
|
164
|
+
// numeric event keys), so it falls through to the default path rather than
|
|
165
|
+
// silently registering nothing.
|
|
166
|
+
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
167
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
171
|
+
try {
|
|
172
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
173
|
+
} catch {
|
|
174
|
+
// a plugin without hooks contributes nothing
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
|
|
180
|
+
* is reported by name and skipped, so one bad entry costs its own hooks, not the
|
|
181
|
+
* session's tool calls. */
|
|
182
|
+
function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
|
|
183
|
+
const candidate = entry as HookMatcher | null
|
|
184
|
+
if (candidate === null || typeof candidate !== 'object') {
|
|
185
|
+
console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
|
|
189
|
+
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
|
|
193
|
+
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A hook entry's display identity for the /hooks viewer: the command for shell
|
|
200
|
+
* hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
|
|
201
|
+
* is named rather than hidden, since a misconfigured entry is exactly what the
|
|
202
|
+
* viewer exists to surface. */
|
|
203
|
+
function hookIdentity(hook: HookCommand | null | undefined): string {
|
|
204
|
+
// A hand-edited settings file can leave a null (or otherwise empty) entry in a
|
|
205
|
+
// hooks array; name it rather than let it crash the viewer that exists to surface
|
|
206
|
+
// exactly this kind of misconfiguration.
|
|
207
|
+
const record: Partial<HookCommand> = hook ?? {}
|
|
208
|
+
const type = record.type ?? 'command'
|
|
209
|
+
if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
|
|
210
|
+
if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
|
|
211
|
+
if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
|
|
212
|
+
return `command: ${record.command ?? '(missing command)'}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Render the resolved hooks config as a readable per-event summary for /hooks:
|
|
216
|
+
* one line per configured hook with its matcher, identity and, when known, the
|
|
217
|
+
* settings file it came from. Pure formatting of already-resolved data. */
|
|
218
|
+
export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
|
|
219
|
+
const lines: string[] = []
|
|
220
|
+
for (const [event, matchers] of Object.entries(config)) {
|
|
221
|
+
const entryLines: string[] = []
|
|
222
|
+
for (const entry of matchers) {
|
|
223
|
+
const matcher = entry.matcher || '*'
|
|
224
|
+
const source = sources?.get(entry)
|
|
225
|
+
const suffix = source ? ` (${source})` : ''
|
|
226
|
+
for (const hook of entry.hooks ?? []) {
|
|
227
|
+
entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
|
|
231
|
+
}
|
|
232
|
+
if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
|
|
233
|
+
return lines.join('\n')
|
|
234
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning hook output into decisions: parsing a hook's exit code / JSON into a
|
|
3
|
+
* block-or-allow verdict, running the gated PreToolUse and UserPromptSubmit passes,
|
|
4
|
+
* and shaping the PostToolUse feedback and system messages surfaced to the user.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import { type HookCommand, type HooksConfig, isRecord } from './config.js'
|
|
9
|
+
import { matchingCommands } from './matcher.js'
|
|
10
|
+
import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
|
|
11
|
+
|
|
12
|
+
export interface HookDecision {
|
|
13
|
+
block: boolean
|
|
14
|
+
reason?: string
|
|
15
|
+
/** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
|
|
16
|
+
* block only on decline. `block` stays true as the no-UI fallback. */
|
|
17
|
+
ask?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text)
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Map a hook's exit code / output to a block-or-allow decision. */
|
|
29
|
+
export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
|
|
30
|
+
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
31
|
+
const parsed = tryParseJson(stdout)
|
|
32
|
+
const specific = parsed?.hookSpecificOutput
|
|
33
|
+
// Claude's "ask" prompts the user; the tool_call handler turns this into a
|
|
34
|
+
// ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
|
|
35
|
+
// headless run with no dialog to show, which is the safe reading on a gated path.
|
|
36
|
+
if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
|
|
37
|
+
if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
38
|
+
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
39
|
+
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
40
|
+
return { block: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
|
|
44
|
+
* in-place mutation, so the target object is emptied and refilled rather than reassigned. */
|
|
45
|
+
function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
|
|
46
|
+
for (const key of Object.keys(target)) delete target[key]
|
|
47
|
+
Object.assign(target, next)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Claude surfaces a hook error notice; on ungated events the action proceeds, while
|
|
51
|
+
* PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
|
|
52
|
+
* their spawnFailed checks). Silence would hide that a guard never ran. */
|
|
53
|
+
function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
|
|
54
|
+
if (!notify) return
|
|
55
|
+
for (const [i, result] of results.entries()) {
|
|
56
|
+
if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
61
|
+
* verdict in config order wins. For MCP tools the matcher sees both the pi name and
|
|
62
|
+
* the Claude alias, and the payload reports the alias, which is the name a
|
|
63
|
+
* Claude-written hook script expects in tool_name. Every hook sees the original
|
|
64
|
+
* tool input; hookSpecificOutput.updatedInput replaces the input in place as each
|
|
65
|
+
* hook completes, so with several rewrites the last to finish takes effect, which
|
|
66
|
+
* is Claude's documented (non-deterministic) behavior. */
|
|
67
|
+
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
|
|
68
|
+
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
69
|
+
const commands = matchingCommands(config.PreToolUse, names)
|
|
70
|
+
const results = await Promise.all(
|
|
71
|
+
commands.map((command) =>
|
|
72
|
+
runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
|
|
73
|
+
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
74
|
+
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
75
|
+
return result
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
surfaceHookFailures(commands, results, onSystemMessage)
|
|
80
|
+
for (const [i, result] of results.entries()) {
|
|
81
|
+
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
82
|
+
// would otherwise read as a clean allow. Fail closed instead.
|
|
83
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
84
|
+
// A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
|
|
85
|
+
// its code 0 must fail closed like a timeout, not read as an allow exactly when
|
|
86
|
+
// the machine is degraded.
|
|
87
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
|
|
88
|
+
}
|
|
89
|
+
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
90
|
+
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
91
|
+
// scan for any deny first, and only fall back to the first ask.
|
|
92
|
+
let ask: HookDecision | undefined
|
|
93
|
+
for (const result of results) {
|
|
94
|
+
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
95
|
+
if (decision.block && !decision.ask) return decision
|
|
96
|
+
if (decision.ask && ask === undefined) ask = decision
|
|
97
|
+
}
|
|
98
|
+
return ask ?? { block: false }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type SystemMessageSink = (message: string) => void
|
|
102
|
+
|
|
103
|
+
/** Claude's universal systemMessage output field: a warning surfaced to the user. */
|
|
104
|
+
export function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
|
|
105
|
+
for (const result of results) {
|
|
106
|
+
const message = tryParseJson(result.stdout)?.systemMessage
|
|
107
|
+
if (message) notify(message)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PromptDecision {
|
|
112
|
+
block: boolean
|
|
113
|
+
reason?: string
|
|
114
|
+
context: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
118
|
+
* hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
|
|
119
|
+
export function promptContext(stdout: string): string {
|
|
120
|
+
const parsed = tryParseJson(stdout)
|
|
121
|
+
if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
|
|
122
|
+
return stdout.trim()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
|
|
126
|
+
* verdict in config order wins; otherwise their additional context is concatenated
|
|
127
|
+
* in config order for injection ahead of the prompt. */
|
|
128
|
+
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
129
|
+
const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
|
|
130
|
+
const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
|
|
131
|
+
surfaceHookFailures(commands, results, onSystemMessage)
|
|
132
|
+
for (const [i, result] of results.entries()) {
|
|
133
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
134
|
+
// No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
|
|
135
|
+
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
|
|
136
|
+
}
|
|
137
|
+
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
138
|
+
const contexts: string[] = []
|
|
139
|
+
for (const result of results) {
|
|
140
|
+
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
141
|
+
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
142
|
+
const context = promptContext(result.stdout)
|
|
143
|
+
if (context) contexts.push(context)
|
|
144
|
+
}
|
|
145
|
+
return { block: false, context: contexts.join('\n') }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
149
|
+
* tool result: a block notice (exit-2 stderr, or decision:block on success) followed
|
|
150
|
+
* by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
|
|
151
|
+
* but never a decision:block verdict. */
|
|
152
|
+
export function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
|
|
153
|
+
const lines: string[] = []
|
|
154
|
+
const parsed = tryParseJson(result.stdout)
|
|
155
|
+
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
156
|
+
// success, exit-2 / decision:block feed back as a block notice.
|
|
157
|
+
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
158
|
+
else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
159
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
160
|
+
if (context) lines.push(context)
|
|
161
|
+
return lines
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
|
|
165
|
+
* an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
|
|
166
|
+
* block would otherwise pay for. */
|
|
167
|
+
export function blockedToolCall(reason: string | undefined): ToolCallEventResult {
|
|
168
|
+
return { block: true, reason, terminate: true }
|
|
169
|
+
}
|