@gotgenes/pi-permission-system 32.0.1 → 32.0.2

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
@@ -5,6 +5,18 @@ 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
+ ## [32.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.1...pi-permission-system-v32.0.2) (2026-09-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** keep a forwarded write when a file lock blocks the atomic rename ([d901116](https://github.com/gotgenes/pi-packages/commit/d9011165203b130675a3affd20a2792f7abd7105)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
14
+ * **pi-permission-system:** create a forwarding directory blocked by a transient file lock ([245683c](https://github.com/gotgenes/pi-packages/commit/245683c9153712d8991ceb21ece7884b2fb12f2a)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** record the transient filesystem retry ([eb7ab58](https://github.com/gotgenes/pi-packages/commit/eb7ab58cfd79d15de77bafa6afe8813e9dff7b72)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
19
+
8
20
  ## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
9
21
 
10
22
 
@@ -2,15 +2,16 @@
2
2
 
3
3
  ## Common Issues
4
4
 
5
- | Problem | Cause | Solution |
6
- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
7
- | Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
8
- | Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
9
- | Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
10
- | `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
11
- | External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
12
- | Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
13
- | Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
5
+ | Problem | Cause | Solution |
6
+ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
8
+ | Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
9
+ | Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
10
+ | `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
11
+ | External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
12
+ | Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
13
+ | Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
14
+ | Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
14
15
 
15
16
  ## Diagnostic Logging
16
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.1",
3
+ "version": "32.0.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,10 @@ import {
29
29
  type ForwardedSessionApproval,
30
30
  type PermissionForwardingLocation,
31
31
  } from "./permission-forwarding";
32
+ import {
33
+ retryOnTransientFsError,
34
+ type TransientFsRetryRecord,
35
+ } from "./transient-fs-retry";
32
36
 
33
37
  /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
34
38
  const UI_PROMPT_SOURCES = [
@@ -217,7 +221,14 @@ export function ensureDirectoryExists(
217
221
  description: string,
218
222
  ): boolean {
219
223
  try {
220
- mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
224
+ recordFsRetry(
225
+ logger,
226
+ "mkdir",
227
+ path,
228
+ retryOnTransientFsError(() => {
229
+ mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
230
+ }),
231
+ );
221
232
  return true;
222
233
  } catch (error) {
223
234
  logPermissionForwardingError(
@@ -399,17 +410,51 @@ export function writeJsonFileAtomic(
399
410
  try {
400
411
  // `rename` preserves the temp file's mode, so setting it here is enough —
401
412
  // a response overwriting an existing file also comes through a fresh temp.
413
+ // The temp write is deliberately outside the retry: its failure shape is a
414
+ // write-denied directory, which no number of attempts resolves.
402
415
  writeFileSync(tempPath, JSON.stringify(value), {
403
416
  encoding: "utf-8",
404
417
  mode: OWNER_ONLY_FILE_MODE,
405
418
  });
406
- renameSync(tempPath, filePath);
419
+ recordFsRetry(
420
+ logger,
421
+ "rename",
422
+ filePath,
423
+ retryOnTransientFsError(() => {
424
+ renameSync(tempPath, filePath);
425
+ }),
426
+ );
407
427
  } catch (error) {
408
428
  safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
409
429
  throw error;
410
430
  }
411
431
  }
412
432
 
433
+ /**
434
+ * Record a filesystem operation that only succeeded after retrying.
435
+ *
436
+ * Debug-only: a recovered write decided nothing, so it does not belong in the
437
+ * permission-decision record — but `attempts` and `code` are the whole
438
+ * diagnosis when a host keeps losing writes, and `debugLog` is exactly the
439
+ * switch a user reaching for that diagnosis turns on.
440
+ */
441
+ function recordFsRetry(
442
+ logger: DebugReviewLogger | null,
443
+ operation: "rename" | "mkdir",
444
+ path: string,
445
+ record: TransientFsRetryRecord,
446
+ ): void {
447
+ if (record.attempts === 1) {
448
+ return;
449
+ }
450
+ logger?.debug("permission_forwarding.fs_retried", {
451
+ operation,
452
+ path,
453
+ attempts: record.attempts,
454
+ code: record.code,
455
+ });
456
+ }
457
+
413
458
  export function readForwardedPermissionRequest(
414
459
  logger: DebugReviewLogger | null,
415
460
  filePath: string,
@@ -0,0 +1,118 @@
1
+ /**
2
+ * transient-fs-retry.ts — Survive a file lock that is about to clear.
3
+ *
4
+ * On Windows a `rename` or `mkdir` is not the POSIX operation of the same
5
+ * name: an antivirus scanner, the search indexer, or any other process holding
6
+ * a transient handle on a path fails the call with `EPERM`/`EBUSY`/`EACCES`.
7
+ * The forwarded-permission writes that ride on those calls are then simply
8
+ * lost — a child's request write becomes a refused tool call, and a serving
9
+ * heartbeat goes unpublished (#914).
10
+ *
11
+ * A few attempts a few milliseconds apart is enough for the common case. The
12
+ * decision lives here rather than at either call site so both inherit one
13
+ * errno set and one budget, and so it can be tested without a filesystem.
14
+ */
15
+
16
+ /** Tuning seams, injected so a unit test asserts the backoff without sleeping. */
17
+ export interface TransientFsRetryOptions {
18
+ delaysMs?: readonly number[];
19
+ sleep?: (ms: number) => void;
20
+ }
21
+
22
+ /** How an operation that eventually succeeded got there. */
23
+ export interface TransientFsRetryRecord {
24
+ /** Attempts made, counting the first — `1` when it succeeded outright. */
25
+ attempts: number;
26
+ /** The transient errno that forced the retries, or `null` when there were none. */
27
+ code: string | null;
28
+ }
29
+
30
+ /**
31
+ * Run `operation`, retrying while a transient filesystem lock rejects it.
32
+ *
33
+ * Returns how it went, so the caller can record a recovery without this module
34
+ * having to know anything about logging. An error outside the transient set is
35
+ * rethrown immediately with no sleep, keeping a permanent `ENOTDIR`/`ENOENT`
36
+ * exactly as fast as it is today; an exhausted budget rethrows the last error,
37
+ * so every caller's existing `catch` keeps working unchanged.
38
+ */
39
+ export function retryOnTransientFsError(
40
+ operation: () => void,
41
+ options?: TransientFsRetryOptions,
42
+ ): TransientFsRetryRecord {
43
+ const delaysMs = options?.delaysMs ?? DEFAULT_RETRY_DELAYS_MS;
44
+ const sleep = options?.sleep ?? sleepBlocking;
45
+
46
+ let attempts = 0;
47
+ let code: string | null = null;
48
+
49
+ for (;;) {
50
+ try {
51
+ operation();
52
+ return { attempts: attempts + 1, code };
53
+ } catch (error) {
54
+ const transientCode = transientErrorCode(error);
55
+ if (transientCode === null || attempts >= delaysMs.length) {
56
+ throw error;
57
+ }
58
+ code = transientCode;
59
+ sleep(delaysMs[attempts] ?? 0);
60
+ attempts += 1;
61
+ }
62
+ }
63
+ }
64
+
65
+ // ── Module-private ─────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Three retries, 60 ms of blocking at worst.
69
+ *
70
+ * Sized against the two windows it has to fit inside: well under one
71
+ * `PERMISSION_FORWARDING_POLL_INTERVAL_MS` tick, so a retrying heartbeat write
72
+ * cannot push the serving node's timer into the next tick, and far under the
73
+ * grace a forwarding child waits out, so a parent retrying its own writes can
74
+ * never be the reason a child gives up on it.
75
+ */
76
+ const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [10, 20, 30];
77
+
78
+ /**
79
+ * The errno values a transient lock produces on an otherwise-valid operation.
80
+ *
81
+ * This set is the platform gate: `process.platform` is unreadable in `src/`,
82
+ * and a POSIX host that produces one of these on a rename is in the same
83
+ * situation a Windows host is — worth one more attempt, and no worse off for
84
+ * it.
85
+ */
86
+ const TRANSIENT_FS_ERROR_CODES: ReadonlySet<string> = new Set([
87
+ "EPERM",
88
+ "EBUSY",
89
+ "EACCES",
90
+ ]);
91
+
92
+ /** The transient errno `error` carries, or `null` when retrying cannot help. */
93
+ function transientErrorCode(error: unknown): string | null {
94
+ if (typeof error !== "object" || error === null || !("code" in error)) {
95
+ return null;
96
+ }
97
+ const { code } = error as { code?: unknown };
98
+ return typeof code === "string" && TRANSIENT_FS_ERROR_CODES.has(code)
99
+ ? code
100
+ : null;
101
+ }
102
+
103
+ /**
104
+ * Block the calling thread for `ms`.
105
+ *
106
+ * Synchronous because every caller is: the heartbeat publish satisfies a
107
+ * `void` seam a timer drives. `Atomics.wait` on a never-notified buffer is the
108
+ * standard blocking sleep and is permitted on Node's main thread.
109
+ *
110
+ * The buffer is module-scoped — which persists across same-cwd session
111
+ * switches — and that is safe here precisely because nothing ever writes to
112
+ * it: every wait sees the initial zero and times out.
113
+ */
114
+ const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
115
+
116
+ function sleepBlocking(ms: number): void {
117
+ Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
118
+ }