@jameslovespancakes/pi-plus 1.0.7 → 1.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "description": "Consolidated pi extensions: Claude subscription accounts and quota HUD, benchmark-driven model catalog and billing policy, live agent board, and capacity-gated remote test workers.",
6
6
  "license": "MIT",
@@ -163,7 +163,17 @@ export function applyQuotaHeaders(
163
163
  const account = storage?.accounts.find((a) => a.id === accountId);
164
164
  if (!account) return false;
165
165
 
166
- saveAccount({ ...account, quota: { ...fresh, scoped: account.quota?.scoped } });
166
+ // Headers arrive on every response, but utilisation moves in whole percent
167
+ // steps over windows of hours. Rewriting two credential files per request to
168
+ // store an unchanged number is pure write amplification, and on Windows each
169
+ // rewrite is another chance for the rename to collide with a file lock.
170
+ const previous = account.quota;
171
+ const unchanged =
172
+ previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
173
+ && previous?.seven_day?.usedPercent === fresh.seven_day?.usedPercent;
174
+ if (unchanged) return false;
175
+
176
+ saveAccount({ ...account, quota: { ...fresh, scoped: previous?.scoped } });
167
177
  return true;
168
178
  }
169
179
 
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
@@ -93,14 +93,45 @@ function readJson<T>(path: string): T | undefined {
93
93
  }
94
94
  }
95
95
 
96
- /** Temp-file + rename, so a crash cannot leave a half-written credential file. */
96
+ /**
97
+ * Temp-file + rename, so a crash cannot leave a half-written credential file.
98
+ *
99
+ * The rename is retried because on Windows it fails with EPERM whenever the
100
+ * destination is momentarily held open by someone else: a virus scanner
101
+ * examining the file we just wrote, the search indexer, or another pi session
102
+ * reading it. The temp name carries the pid so concurrent writers never share
103
+ * a temp file, but they can still collide on the destination.
104
+ *
105
+ * Retries are brief and synchronous. Losing a quota update is harmless, so a
106
+ * final failure removes the temp file and gives up rather than throwing into
107
+ * a live request.
108
+ */
97
109
  function writeJson(path: string, value: unknown): void {
98
110
  mkdirSync(dirname(path), { recursive: true });
99
111
  const temp = `${path}.${process.pid}.tmp`;
100
112
  writeFileSync(temp, JSON.stringify(value, undefined, 2), { encoding: "utf8", mode: 0o600 });
101
- renameSync(temp, path);
113
+
114
+ const TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
115
+ for (let attempt = 0; ; attempt++) {
116
+ try {
117
+ renameSync(temp, path);
118
+ return;
119
+ } catch (error: any) {
120
+ if (attempt >= 5 || !TRANSIENT.has(error?.code)) {
121
+ try { rmSync(temp, { force: true }); } catch { /* best effort */ }
122
+ if (attempt >= 5) return; // transient and unresolved: drop this write
123
+ throw error;
124
+ }
125
+ // 1, 2, 4, 8, 16ms. Long enough for a scanner to release the handle,
126
+ // short enough not to stall the request that triggered it.
127
+ Atomics.wait(SPIN, 0, 0, 2 ** attempt);
128
+ }
129
+ }
102
130
  }
103
131
 
132
+ /** Backing store for the synchronous sleep above. */
133
+ const SPIN = new Int32Array(new SharedArrayBuffer(4));
134
+
104
135
  const pick = <T extends object>(source: any, keys: readonly string[]): T =>
105
136
  Object.fromEntries(keys.filter((k) => source?.[k] !== undefined).map((k) => [k, source[k]])) as T;
106
137
 
@@ -73,9 +73,16 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
73
73
  return primary;
74
74
  }
75
75
 
76
- lastSelected = { id: picked.candidate.id, at: Date.now() };
76
+ const now = Date.now();
77
+ lastSelected = { id: picked.candidate.id, at: now };
77
78
  if (picked.candidate.account) {
78
- saveAccount({ ...picked.candidate.account, lastUsed: Date.now() });
79
+ // `lastUsed` only orders the footer's account list, so second-level
80
+ // precision buys nothing. Persisting it on every request rewrote two
81
+ // credential files per call; once a minute is indistinguishable in the UI.
82
+ const previous = picked.candidate.account.lastUsed ?? 0;
83
+ if (now - previous > 60_000) {
84
+ saveAccount({ ...picked.candidate.account, lastUsed: now });
85
+ }
79
86
  }
80
87
  return picked.candidate.access ?? primary;
81
88
  }