@fyeeme/pi-hooks 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/index.ts +31 -4
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.0.4] - 2026-09-12
9
+
10
+ ### Added
11
+
12
+ - `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).
9
13
 
10
14
  ## [1.0.3] - 2026-09-09
11
15
 
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
  }
@@ -282,6 +304,7 @@ async function runCommand(
282
304
  cwd: string,
283
305
  stdinText: string,
284
306
  timeoutMs: number,
307
+ denyAsContext: boolean,
285
308
  signal?: AbortSignal,
286
309
  ): Promise<HookResult> {
287
310
  return new Promise((resolve) => {
@@ -377,7 +400,8 @@ async function runCommand(
377
400
  // parseHookOutput honors it as a block, so don't log it as a failure.
378
401
  if (code !== 0 && code !== 2 && code !== null) console.error(`[hooks] exited ${code}: ${command}`);
379
402
  const stdout = Buffer.concat(chunks).toString("utf8").trim();
380
- finish(parseHookOutput(command, stdout, code));
403
+ const result = parseHookOutput(command, stdout, code);
404
+ finish(denyAsContext ? applyDenyAsContext(result) : result);
381
405
  });
382
406
 
383
407
  proc.on("error", (err) => {
@@ -407,13 +431,14 @@ async function runGroups(
407
431
  ): Promise<{ contexts: string[]; block: string | null }> {
408
432
  if (!groups) return { contexts: [], block: null };
409
433
 
410
- const commands: Array<{ command: string; timeoutMs: number }> = [];
434
+ const commands: Array<{ command: string; timeoutMs: number; denyAsContext: boolean }> = [];
411
435
  for (const group of groups) {
412
436
  if (!matchTool(group.matcher, toolName)) continue;
413
437
  for (const hook of group.hooks) {
414
438
  commands.push({
415
439
  command: hook.command,
416
440
  timeoutMs: (hook.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
441
+ denyAsContext: hook.denyAsContext === true,
417
442
  });
418
443
  }
419
444
  }
@@ -425,7 +450,9 @@ async function runGroups(
425
450
  const results: HookResult[] = [];
426
451
  for (let i = 0; i < commands.length; i += MAX_CONCURRENT) {
427
452
  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)))));
453
+ results.push(
454
+ ...(await Promise.all(batch.map((c) => runCommand(c.command, cwd, stdinText, c.timeoutMs, c.denyAsContext, signal)))),
455
+ );
429
456
  }
430
457
 
431
458
  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.4",
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",