@fyeeme/pi-hooks 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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/index.ts +43 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.5] - 2026-09-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A bare exit code 2 with no parseable JSON on stdout is no longer treated as a deny: it is logged as a suspected broken hook command and the tool call proceeds. A crash or misconfiguration (e.g. `python3` exiting 2 for "can't open file") previously hard-blocked every tool call for the rest of the session. Structured JSON denies (`permissionDecision: "deny"`, or exit 2 carrying a deny payload) are unchanged.
|
|
15
|
+
|
|
16
|
+
## [1.0.4] - 2026-09-12
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `denyAsContext` per-hook flag: a deny (`permissionDecision: "deny"` or exit code 2) is demoted to `additionalContext` — the tool call proceeds and the nudge text is injected before the next LLM call instead of blocking the agent (e.g. `serena-hooks remind` stops killing read bursts).
|
|
21
|
+
|
|
10
22
|
## [1.0.3] - 2026-09-09
|
|
11
23
|
|
|
12
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -136,7 +136,8 @@ Commands may return JSON on stdout, or control flow via exit codes:
|
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
- exit code **0** with `additionalContext` → context injected.
|
|
139
|
-
- exit code **2** (PreToolUse) → tool call blocked (`terminate: true`); reason fed to the model. `terminate` skips the follow-up LLM call only when the denied call is in an all-terminating batch (pi >= 0.84.1, #7715); in a multi-tool batch the block always applies but the agent may continue.
|
|
139
|
+
- exit code **2** (PreToolUse) **with a JSON deny payload** (`permissionDecision: "deny"`) → tool call blocked (`terminate: true`); reason fed to the model. `terminate` skips the follow-up LLM call only when the denied call is in an all-terminating batch (pi >= 0.84.1, #7715); in a multi-tool batch the block always applies but the agent may continue.
|
|
140
|
+
- exit code **2** without parseable JSON (e.g. a broken command like `python3` failing to open a script) → treated as a crash, not a deny: warning on stderr, tool call proceeds. This keeps a misconfigured hook from hard-blocking every tool call.
|
|
140
141
|
- exit code **2** (Stop) → ignored (pi cannot block exit).
|
|
141
142
|
- other non-zero → logged, execution continues.
|
|
142
143
|
- non-JSON stdout → logged as a warning, ignored.
|
package/index.ts
CHANGED
|
@@ -51,6 +51,11 @@ interface HookEntry {
|
|
|
51
51
|
command: string;
|
|
52
52
|
/** Per-hook timeout in seconds (Claude Code compatible). Default 60. */
|
|
53
53
|
timeout?: number;
|
|
54
|
+
/** Demote deny (`permissionDecision: "deny"` or exit code 2) to
|
|
55
|
+
* `additionalContext`: the tool call proceeds and the nudge text is
|
|
56
|
+
* injected before the next LLM call instead of blocking the agent
|
|
57
|
+
* (e.g. `serena-hooks remind`). */
|
|
58
|
+
denyAsContext?: boolean;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
interface HookGroup {
|
|
@@ -105,7 +110,12 @@ export function normalizeConfig(raw: unknown): HooksConfig | null {
|
|
|
105
110
|
if (he.type !== "command" || typeof he.command !== "string") continue;
|
|
106
111
|
const timeout =
|
|
107
112
|
typeof he.timeout === "number" && he.timeout > 0 ? he.timeout : undefined;
|
|
108
|
-
entries.push({
|
|
113
|
+
entries.push({
|
|
114
|
+
type: "command",
|
|
115
|
+
command: he.command,
|
|
116
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
117
|
+
...(he.denyAsContext === true ? { denyAsContext: true } : {}),
|
|
118
|
+
});
|
|
109
119
|
}
|
|
110
120
|
if (entries.length > 0) groups.push({ matcher: gr.matcher, hooks: entries });
|
|
111
121
|
}
|
|
@@ -242,6 +252,18 @@ export interface HookResult {
|
|
|
242
252
|
block: string | null;
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Demote a deny to a pure context hint (`denyAsContext` per-hook flag): the
|
|
257
|
+
* tool call proceeds and the nudge text rides along before the next LLM call
|
|
258
|
+
* instead of blocking the agent. Prefers the hook's own `additionalContext`
|
|
259
|
+
* (e.g. serena-hooks remind's "Consider using Serena's symbolic tools...")
|
|
260
|
+
* and falls back to the block reason.
|
|
261
|
+
*/
|
|
262
|
+
export function applyDenyAsContext(result: HookResult): HookResult {
|
|
263
|
+
if (!result.block) return result;
|
|
264
|
+
return { context: result.context ?? result.block, block: null };
|
|
265
|
+
}
|
|
266
|
+
|
|
245
267
|
function emptyResult(): HookResult {
|
|
246
268
|
return { context: null, block: null };
|
|
247
269
|
}
|
|
@@ -262,6 +284,18 @@ export function parseHookOutput(command: string, stdout: string, exitCode: numbe
|
|
|
262
284
|
}
|
|
263
285
|
}
|
|
264
286
|
|
|
287
|
+
// A bare exit code 2 with no parseable stdout is indistinguishable from a
|
|
288
|
+
// broken hook command (e.g. `python3` exits 2 for "can't open file"), and
|
|
289
|
+
// taking it as a deny hard-blocks EVERY tool call for the rest of the
|
|
290
|
+
// session. Treat the unstructured case as a suspected crash instead: warn
|
|
291
|
+
// and allow. A structured JSON deny still blocks, whatever the exit code.
|
|
292
|
+
if (exitCode === 2 && output === null) {
|
|
293
|
+
console.error(
|
|
294
|
+
`[hooks] exit 2 without a JSON deny payload from ${command} — likely a broken command, not a deny; allowing the call`,
|
|
295
|
+
);
|
|
296
|
+
return { context: null, block: null };
|
|
297
|
+
}
|
|
298
|
+
|
|
265
299
|
const context = output?.hookSpecificOutput?.additionalContext ?? null;
|
|
266
300
|
const deny = exitCode === 2 || output?.hookSpecificOutput?.permissionDecision === "deny";
|
|
267
301
|
const reason =
|
|
@@ -282,6 +316,7 @@ async function runCommand(
|
|
|
282
316
|
cwd: string,
|
|
283
317
|
stdinText: string,
|
|
284
318
|
timeoutMs: number,
|
|
319
|
+
denyAsContext: boolean,
|
|
285
320
|
signal?: AbortSignal,
|
|
286
321
|
): Promise<HookResult> {
|
|
287
322
|
return new Promise((resolve) => {
|
|
@@ -377,7 +412,8 @@ async function runCommand(
|
|
|
377
412
|
// parseHookOutput honors it as a block, so don't log it as a failure.
|
|
378
413
|
if (code !== 0 && code !== 2 && code !== null) console.error(`[hooks] exited ${code}: ${command}`);
|
|
379
414
|
const stdout = Buffer.concat(chunks).toString("utf8").trim();
|
|
380
|
-
|
|
415
|
+
const result = parseHookOutput(command, stdout, code);
|
|
416
|
+
finish(denyAsContext ? applyDenyAsContext(result) : result);
|
|
381
417
|
});
|
|
382
418
|
|
|
383
419
|
proc.on("error", (err) => {
|
|
@@ -407,13 +443,14 @@ async function runGroups(
|
|
|
407
443
|
): Promise<{ contexts: string[]; block: string | null }> {
|
|
408
444
|
if (!groups) return { contexts: [], block: null };
|
|
409
445
|
|
|
410
|
-
const commands: Array<{ command: string; timeoutMs: number }> = [];
|
|
446
|
+
const commands: Array<{ command: string; timeoutMs: number; denyAsContext: boolean }> = [];
|
|
411
447
|
for (const group of groups) {
|
|
412
448
|
if (!matchTool(group.matcher, toolName)) continue;
|
|
413
449
|
for (const hook of group.hooks) {
|
|
414
450
|
commands.push({
|
|
415
451
|
command: hook.command,
|
|
416
452
|
timeoutMs: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
453
|
+
denyAsContext: hook.denyAsContext === true,
|
|
417
454
|
});
|
|
418
455
|
}
|
|
419
456
|
}
|
|
@@ -425,7 +462,9 @@ async function runGroups(
|
|
|
425
462
|
const results: HookResult[] = [];
|
|
426
463
|
for (let i = 0; i < commands.length; i += MAX_CONCURRENT) {
|
|
427
464
|
const batch = commands.slice(i, i + MAX_CONCURRENT);
|
|
428
|
-
results.push(
|
|
465
|
+
results.push(
|
|
466
|
+
...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, c.denyAsContext, signal)))),
|
|
467
|
+
);
|
|
429
468
|
}
|
|
430
469
|
|
|
431
470
|
const contexts: string[] = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-hooks",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Claude Code-compatible hooks runner for pi. Reads hooks config (priority: ~/.pi/agent/hooks.json, then project .pi/hooks.json) and maps SessionStart, PreToolUse, and Stop events to pi lifecycle events.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|