@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 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({ type: "command", command: he.command, ...(timeout === undefined ? {} : { timeout }) });
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
- finish(parseHookOutput(command, stdout, code));
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(...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, signal)))));
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",
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",