@cad0p/pi-usage 0.11.5 → 0.11.6

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Run the potentially large local session scan away from Pi's TUI event loop.
3
+ * The worker runs the plain Node ESM entry point; the extension itself may be
4
+ * loaded through jiti, but this worker never needs that loader.
5
+ *
6
+ * The scan can outlive the surface that asked for it. A cold scan of a
7
+ * multi-gigabyte session store takes seconds, and the user may `/quit` or
8
+ * `/reload` a second later: a thread left behind keeps reading session files
9
+ * after the session is gone and holds `local-sessions.json.lock` until the
10
+ * 120 s stale timeout, so every later scan is read-only with a warning. The
11
+ * handle owns that lifecycle — the host stops it, and the stop always ends with
12
+ * the lock released and no half-written state.
13
+ */
14
+
15
+ import { Worker } from "node:worker_threads";
16
+
17
+ import { releaseHistoryLock } from "./history.js";
18
+
19
+ /**
20
+ * How long a stopping scan gets to notice the flag and release its own lock
21
+ * before the thread is terminated and the parent releases the lock instead.
22
+ */
23
+ export const DEFAULT_STOP_GRACE_MS = 2_000;
24
+
25
+ /** A scan the host stopped, told apart from a failure by its name. */
26
+ function scanStoppedError() {
27
+ const error = new Error("local token stats scan stopped");
28
+ error.name = "AbortError";
29
+ return error;
30
+ }
31
+
32
+ /**
33
+ * Start the scan in a worker thread.
34
+ *
35
+ * The stop request is a shared flag the scan polls rather than a message it has
36
+ * to be idle to receive: a cold scan spends its time inside synchronous reads
37
+ * and hashing, where a queued message would only be seen after the work it is
38
+ * meant to interrupt.
39
+ *
40
+ * @param {import("./pi-sessions.js").LocalStatsOptions} [options]
41
+ * @param {{
42
+ * graceMs?: number,
43
+ * spawn?: () => { on: Function, once: Function, postMessage: Function, terminate: () => Promise<number> },
44
+ * releaseLock?: (lock: { path: string, token: string | null }) => boolean,
45
+ * }} [lifecycle] Test seams: a fake worker and a zero grace period.
46
+ * @returns {{
47
+ * result: Promise<Awaited<ReturnType<typeof import("./pi-sessions.js").collectLocalStats>>>,
48
+ * stop: () => Promise<void>,
49
+ * }}
50
+ */
51
+ export function startLocalStatsWorker(options = {}, lifecycle = {}) {
52
+ const graceMs = Number.isFinite(lifecycle.graceMs)
53
+ ? Math.max(0, /** @type {number} */ (lifecycle.graceMs))
54
+ : DEFAULT_STOP_GRACE_MS;
55
+ const stopFlag = new Int32Array(new SharedArrayBuffer(4));
56
+ const releaseLock = lifecycle.releaseLock ?? releaseHistoryLock;
57
+ const worker = lifecycle.spawn
58
+ ? lifecycle.spawn()
59
+ : new Worker(new URL("./stats-worker.js", import.meta.url), { workerData: { options, stopFlag } });
60
+
61
+ /** The lock the scan told us it holds; only its own token may remove it. */
62
+ /** @type {{ path: string, token: string | null } | null} */
63
+ let lock = null;
64
+ /** @type {{ ok: true, result: any } | { ok: false, aborted: boolean, error: string } | null} */
65
+ let outcome = null;
66
+ let stopping = false;
67
+ let settled = false;
68
+ /** @type {(value: any) => void} */
69
+ let resolveResult = () => {};
70
+ /** @type {(error: Error) => void} */
71
+ let rejectResult = () => {};
72
+ const result = new Promise((resolve, reject) => {
73
+ resolveResult = resolve;
74
+ rejectResult = reject;
75
+ });
76
+
77
+ const finish = () => {
78
+ if (settled) return;
79
+ settled = true;
80
+ const owned = lock;
81
+ lock = null;
82
+ // A scan that ended on its own already released this in its own `finally`;
83
+ // a thread that had to be killed did not, and its token is the only proof
84
+ // the file is still ours.
85
+ if (owned) releaseLock(owned);
86
+ if (outcome?.ok) resolveResult(outcome.result);
87
+ else rejectResult(outcome?.aborted ? scanStoppedError() : new Error(outcome?.error ?? "the worker thread failed"));
88
+ };
89
+
90
+ worker.on("message", (message) => {
91
+ if (!message || typeof message !== "object") return;
92
+ if (message.type === "lock") {
93
+ if (typeof message.path === "string") {
94
+ lock = { path: message.path, token: typeof message.token === "string" ? message.token : null };
95
+ }
96
+ return;
97
+ }
98
+ if (message.type !== "result") return;
99
+ outcome = message.ok
100
+ ? { ok: true, result: message.result }
101
+ : {
102
+ ok: false,
103
+ aborted: message.aborted === true,
104
+ error: typeof message.error === "string" ? message.error : "the scan failed",
105
+ };
106
+ });
107
+ worker.once("error", () => {
108
+ // A thread that died (OOM, a broken entry point) never posts a result.
109
+ if (!outcome) outcome = { ok: false, aborted: stopping, error: "the worker thread failed" };
110
+ });
111
+ const exited = new Promise((resolve) => {
112
+ worker.once("exit", (code) => {
113
+ if (!outcome) {
114
+ outcome = stopping
115
+ ? { ok: false, aborted: true, error: "local token stats scan stopped" }
116
+ : { ok: false, aborted: false, error: "the worker thread exited without a result" };
117
+ }
118
+ finish();
119
+ resolve(code);
120
+ });
121
+ });
122
+
123
+ /**
124
+ * End the scan: ask it to stop, terminate the thread if it does not, and
125
+ * leave the report cache and the history exactly as they were found.
126
+ */
127
+ const stop = async () => {
128
+ if (stopping) return;
129
+ stopping = true;
130
+ Atomics.store(stopFlag, 0, 1);
131
+ if (!settled) {
132
+ const stopped = await Promise.race([
133
+ exited.then(() => true),
134
+ new Promise((resolve) => {
135
+ const timer = setTimeout(() => resolve(false), graceMs);
136
+ timer.unref?.();
137
+ }),
138
+ ]);
139
+ if (!stopped) await worker.terminate().catch(() => 0);
140
+ }
141
+ await exited;
142
+ finish();
143
+ };
144
+
145
+ return { result, stop };
146
+ }
147
+
148
+ /**
149
+ * Await one scan to completion. The caller that wants the lifecycle uses
150
+ * {@link startLocalStatsWorker} instead.
151
+ *
152
+ * @param {import("./pi-sessions.js").LocalStatsOptions} [options]
153
+ * @returns {Promise<Awaited<ReturnType<typeof import("./pi-sessions.js").collectLocalStats>>>}
154
+ */
155
+ export function collectLocalStatsInWorker(options = {}) {
156
+ return startLocalStatsWorker(options).result;
157
+ }
package/src/model.js CHANGED
@@ -33,6 +33,10 @@
33
33
  * @property {string} source Credential source description.
34
34
  * @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
35
35
  * @property {string | null} expiresInMin Credential freshness, when known.
36
+ * @property {true} [tokenRenewedInMemory] Present only when the provider renewed
37
+ * this token in memory for this run: the
38
+ * stored credential's freshness then no
39
+ * longer describes the request made.
36
40
  */
37
41
 
38
42
  /**
@@ -247,6 +251,7 @@ export function humanReset(seconds) {
247
251
  * source: string,
248
252
  * now?: number,
249
253
  * expiresInMin?: number | null,
254
+ * tokenRenewedInMemory?: boolean,
250
255
  * windows?: QuotaWindow[],
251
256
  * }} input
252
257
  * @returns {QuotaResult}
@@ -256,7 +261,7 @@ export function degradedResult(input) {
256
261
  input.notConfigured ??
257
262
  (typeof input.error === "string" && input.error.startsWith("no ") && input.error.includes("credential in the Pi store")),
258
263
  );
259
- return {
264
+ const result = {
260
265
  family: input.family,
261
266
  label: input.label,
262
267
  account: input.account ?? "unknown",
@@ -270,6 +275,27 @@ export function degradedResult(input) {
270
275
  sourceKind: input.sourceKind,
271
276
  expiresInMin: input.expiresInMin ?? null,
272
277
  };
278
+ // Only ever present when a provider renewed the token in memory: a field on
279
+ // every result would be one more thing the JSON feed promises to keep.
280
+ if (input.tokenRenewedInMemory === true) result.tokenRenewedInMemory = true;
281
+ return result;
282
+ }
283
+
284
+ /**
285
+ * Which field `displayIdentity` would print for this credential.
286
+ *
287
+ * A provider that adds a second identifier of its own (Antigravity's Cloud Code
288
+ * project) needs to know whether the identity already *is* that identifier, or
289
+ * it prints the same value twice.
290
+ *
291
+ * @param {{ email?: string | null, accountId?: string | null, projectId?: string | null, identity?: string }} credential
292
+ * @returns {"email" | "accountId" | "projectId" | "identity" | "none"}
293
+ */
294
+ export function identityKind(credential) {
295
+ if (credential.email) return "email";
296
+ if (credential.accountId) return "accountId";
297
+ if (credential.projectId) return "projectId";
298
+ return credential.identity ? "identity" : "none";
273
299
  }
274
300
 
275
301
  /**
@@ -279,10 +305,18 @@ export function degradedResult(input) {
279
305
  * @returns {string}
280
306
  */
281
307
  export function displayIdentity(credential) {
282
- if (credential.email) return credential.email;
283
- if (credential.accountId) return `${credential.accountId.slice(0, 8)}…`;
284
- if (credential.projectId) return `project ${credential.projectId.slice(0, 8)}…`;
285
- return credential.identity ? `id ${credential.identity.slice(0, 8)}…` : "unknown";
308
+ switch (identityKind(credential)) {
309
+ case "email":
310
+ return /** @type {string} */ (credential.email);
311
+ case "accountId":
312
+ return `${/** @type {string} */ (credential.accountId).slice(0, 8)}…`;
313
+ case "projectId":
314
+ return `project ${/** @type {string} */ (credential.projectId).slice(0, 8)}…`;
315
+ case "identity":
316
+ return `id ${/** @type {string} */ (credential.identity).slice(0, 8)}…`;
317
+ default:
318
+ return "unknown";
319
+ }
286
320
  }
287
321
 
288
322
  /**
@@ -61,7 +61,7 @@ function writeRecord(dir, id, record) {
61
61
  * no longer present.
62
62
  *
63
63
  * @param {Array<{ id: string, record: Record<string, unknown> }>} records
64
- * @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string }} [options]
64
+ * @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string, prune?: boolean }} [options]
65
65
  * @returns {{ dir: string, written: string[], removed: string[] }}
66
66
  */
67
67
  export function publishRecords(records, options = {}) {
@@ -74,15 +74,16 @@ export function publishRecords(records, options = {}) {
74
74
  written.push(writeRecord(dir, id, record));
75
75
  }
76
76
 
77
- const keep = new Set(records.map(({ id }) => `${id}.json`));
78
77
  const removed = [];
79
- for (const name of readdirSync(dir)) {
80
- if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
81
- if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
82
- rmSync(join(dir, name), { force: true });
83
- removed.push(join(dir, name));
78
+ if (options.prune !== false) {
79
+ const keep = new Set(records.map(({ id }) => `${id}.json`));
80
+ for (const name of readdirSync(dir)) {
81
+ if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
82
+ if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
83
+ rmSync(join(dir, name), { force: true });
84
+ removed.push(join(dir, name));
85
+ }
84
86
  }
85
87
 
86
-
87
88
  return { dir, written, removed };
88
89
  }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Raw key decoding for the /usage overlay.
3
+ *
4
+ * Terminals speak two encodings for the keys the overlay answers. Legacy
5
+ * terminals send the familiar escape sequences (`\x1b[A` for Up, `\x1b[5~` for
6
+ * PgUp), and terminals that negotiate the kitty keyboard protocol — Ghostty
7
+ * among them, because Pi asks for flags 1|2|4 — send CSI sequences carrying a
8
+ * 1-based modifier mask and an event type (`\x1b[1;1:1A` for a press,
9
+ * `\x1b[1;1:2A` for a repeat, `\x1b[1;1:3A` for a release), and may report a
10
+ * printable key as its codepoint (`\x1b[106u` for `j`).
11
+ *
12
+ * This mirrors the subset of pi-tui's `matchesKey`, `isKeyRelease`,
13
+ * `isKeyRepeat` and `decodeKittyPrintable` the overlay needs. It is local on
14
+ * purpose. Pi maps `@earendil-works/pi-tui` for an extension at load time
15
+ * through jiti's virtual modules, so a static import would work inside Pi — but
16
+ * the test suite loads the extension with Node's type stripping, where a bare
17
+ * specifier resolves only from a `node_modules` this package does not have and
18
+ * never installs: it ships zero runtime and dev dependencies, and CI runs
19
+ * `npm test` with no install step. Declaring pi-tui a peer/dev dependency would
20
+ * only move that install into CI and every contributor's checkout, and injecting
21
+ * the matcher would still need a real implementation for the tests. One local
22
+ * decoder is a single code path that the unit table, the overlay tests and the
23
+ * live tmux proof all exercise.
24
+ *
25
+ * A release is never a press: the overlay drops it before acting. A repeat is a
26
+ * press. A held arrow must keep moving, and in a legacy terminal the OS repeat
27
+ * is byte-identical to the first press, so ignoring kitty repeats would make a
28
+ * held key behave differently in Ghostty than in tmux.
29
+ */
30
+
31
+ const LOCK_MASK = 64 + 128; // Caps Lock and Num Lock ride along in the modifier field
32
+ const MODIFIER_SHIFT = 1;
33
+
34
+ /** Kitty reserves this Private Use Area range for functional keys. */
35
+ const KITTY_FUNCTIONAL_START = 57344;
36
+ const KITTY_FUNCTIONAL_END = 63743;
37
+
38
+ /** Internal codepoints: negative so a reported codepoint can never collide. */
39
+ const ARROW = { up: -1, down: -2, right: -3, left: -4 };
40
+ const FUNCTIONAL = { delete: -10, insert: -11, pageUp: -12, pageDown: -13, home: -14, end: -15 };
41
+
42
+ /** The final letter of a `CSI A/B/C/D` arrow sequence. */
43
+ const ARROW_BY_LETTER = { A: ARROW.up, B: ARROW.down, C: ARROW.right, D: ARROW.left };
44
+
45
+ /** Codepoints of the keys the overlay names. */
46
+ const KEY_CODEPOINTS = {
47
+ escape: 27,
48
+ enter: 13,
49
+ tab: 9,
50
+ space: 32,
51
+ ...ARROW,
52
+ ...FUNCTIONAL,
53
+ };
54
+
55
+ /**
56
+ * Kitty's Private Use Area spellings of the same keys, including the keypad
57
+ * aliases (57417 left … 57424 end, 57414 keypad enter).
58
+ *
59
+ * @type {Map<number, number>}
60
+ */
61
+ const KITTY_EQUIVALENTS = new Map([
62
+ [57350, ARROW.left],
63
+ [57351, ARROW.right],
64
+ [57352, ARROW.up],
65
+ [57353, ARROW.down],
66
+ [57354, FUNCTIONAL.pageUp],
67
+ [57355, FUNCTIONAL.pageDown],
68
+ [57356, FUNCTIONAL.home],
69
+ [57357, FUNCTIONAL.end],
70
+ [57414, KEY_CODEPOINTS.enter],
71
+ [57417, ARROW.left],
72
+ [57418, ARROW.right],
73
+ [57419, ARROW.up],
74
+ [57420, ARROW.down],
75
+ [57421, FUNCTIONAL.pageUp],
76
+ [57422, FUNCTIONAL.pageDown],
77
+ [57423, FUNCTIONAL.home],
78
+ [57424, FUNCTIONAL.end],
79
+ ]);
80
+
81
+ /** What a legacy terminal (or tmux, in either cursor mode) sends for each key. */
82
+ const LEGACY = {
83
+ escape: ["\u001b"],
84
+ // SS3 M is how xterm family terminals send the numpad Enter.
85
+ enter: ["\r", "\n", "\u001bOM"],
86
+ tab: ["\t"],
87
+ space: [" "],
88
+ up: ["\u001b[A", "\u001bOA"],
89
+ down: ["\u001b[B", "\u001bOB"],
90
+ left: ["\u001b[D", "\u001bOD"],
91
+ right: ["\u001b[C", "\u001bOC"],
92
+ pageUp: ["\u001b[5~", "\u001b[[5~"],
93
+ pageDown: ["\u001b[6~", "\u001b[[6~"],
94
+ home: ["\u001b[H", "\u001bOH", "\u001b[1~", "\u001b[7~"],
95
+ end: ["\u001b[F", "\u001bOF", "\u001b[4~", "\u001b[8~"],
96
+ };
97
+
98
+ /** The number in a `CSI <n> ~` functional sequence. */
99
+ const FUNCTIONAL_BY_NUMBER = { 1: FUNCTIONAL.home, 2: FUNCTIONAL.insert, 3: FUNCTIONAL.delete, 4: FUNCTIONAL.end, 5: FUNCTIONAL.pageUp, 6: FUNCTIONAL.pageDown, 7: FUNCTIONAL.home, 8: FUNCTIONAL.end };
100
+
101
+ const CSI_U = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
102
+ // A modifier field only ever follows "1;" — the bare forms (\x1b[1A, \x1b[1H)
103
+ // are not sequences pi-tui accepts as arrows, so neither do we.
104
+ const CSI_ARROW = /^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/;
105
+ const CSI_HOME_END = /^\x1b\[1;(\d+)(?::(\d+))?([HF])$/;
106
+ const CSI_FUNCTIONAL = /^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/;
107
+ const CSI_MODIFY_OTHER_KEYS = /^\x1b\[27;(\d+);(\d+)~$/;
108
+
109
+ /**
110
+ * Event type from the trailing field: 1 press, 2 repeat, 3 release.
111
+ *
112
+ * @param {string | undefined} value
113
+ * @returns {"press" | "repeat" | "release"}
114
+ */
115
+ function eventType(value) {
116
+ if (value === "2") return "repeat";
117
+ if (value === "3") return "release";
118
+ return "press";
119
+ }
120
+
121
+ /**
122
+ * Normalize a kitty functional codepoint to the internal one.
123
+ *
124
+ * @param {number} codepoint
125
+ * @returns {number}
126
+ */
127
+ function normalize(codepoint) {
128
+ return KITTY_EQUIVALENTS.get(codepoint) ?? codepoint;
129
+ }
130
+
131
+ /**
132
+ * One parsed key event, or null when the bytes are neither a key the overlay
133
+ * knows nor a sequence it can read.
134
+ *
135
+ * @param {string} data
136
+ * @returns {{ codepoint: number, shifted?: number, modifier: number, event: "press" | "repeat" | "release" } | null}
137
+ */
138
+ function parseKey(data) {
139
+ let match = data.match(CSI_MODIFY_OTHER_KEYS);
140
+ if (match) return { codepoint: Number(match[2]), modifier: Number(match[1]) - 1, event: "press" };
141
+
142
+ match = data.match(CSI_U);
143
+ if (match) {
144
+ return {
145
+ codepoint: Number(match[1]),
146
+ shifted: match[2] ? Number(match[2]) : undefined,
147
+ modifier: match[4] ? Number(match[4]) - 1 : 0,
148
+ event: eventType(match[5]),
149
+ };
150
+ }
151
+
152
+ match = data.match(CSI_ARROW);
153
+ if (match) return { codepoint: ARROW_BY_LETTER[match[3]], modifier: Number(match[1]) - 1, event: eventType(match[2]) };
154
+
155
+ match = data.match(CSI_HOME_END);
156
+ if (match) {
157
+ return {
158
+ codepoint: match[3] === "H" ? FUNCTIONAL.home : FUNCTIONAL.end,
159
+ modifier: Number(match[1]) - 1,
160
+ event: eventType(match[2]),
161
+ };
162
+ }
163
+
164
+ match = data.match(CSI_FUNCTIONAL);
165
+ if (match) {
166
+ const codepoint = FUNCTIONAL_BY_NUMBER[Number(match[1])];
167
+ if (codepoint !== undefined) return { codepoint, modifier: match[2] ? Number(match[2]) - 1 : 0, event: eventType(match[3]) };
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Whether raw input is a kitty key release. Only a kitty-protocol terminal
174
+ * reports releases, so legacy input is always a press.
175
+ *
176
+ * @param {string} data
177
+ * @returns {boolean}
178
+ */
179
+ export function isKeyRelease(data) {
180
+ return parseKey(data)?.event === "release";
181
+ }
182
+
183
+ /**
184
+ * Whether raw input is a kitty key repeat (the terminal auto-repeating a held
185
+ * key). The overlay treats it as a press; see the module comment.
186
+ *
187
+ * @param {string} data
188
+ * @returns {boolean}
189
+ */
190
+ export function isKeyRepeat(data) {
191
+ return parseKey(data)?.event === "repeat";
192
+ }
193
+
194
+ /**
195
+ * Whether raw input is the named key, unmodified.
196
+ *
197
+ * Shift is allowed on enter, space and escape, whose shifted form is the same
198
+ * key in a legacy terminal; Shift+Tab is backtab (`\x1b[Z`), a different key, so
199
+ * it is not tab in either encoding, and any other modifier is a different key
200
+ * (shift+up, ctrl+page-up, …).
201
+ *
202
+ * @param {string} data raw bytes from the terminal
203
+ * @param {"escape" | "enter" | "tab" | "space" | "up" | "down" | "left" | "right" | "pageUp" | "pageDown" | "home" | "end"} name
204
+ * @returns {boolean}
205
+ */
206
+ export function matchesKey(data, name) {
207
+ if ((LEGACY[name] ?? []).includes(data)) return true;
208
+ const expected = KEY_CODEPOINTS[name];
209
+ if (expected === undefined) return false;
210
+ const parsed = parseKey(data);
211
+ if (!parsed) return false;
212
+ const modifier = parsed.modifier & ~LOCK_MASK;
213
+ const allowed = name === "enter" || name === "space" || name === "escape" ? MODIFIER_SHIFT : 0;
214
+ if ((modifier & ~allowed) !== 0) return false;
215
+ return normalize(parsed.codepoint) === expected;
216
+ }
217
+
218
+ /**
219
+ * The printable character a kitty CSI-u or xterm modifyOtherKeys sequence
220
+ * carries, if it is one. Plain and Shift-modified text only: ctrl/alt/super
221
+ * combinations are key bindings, not text.
222
+ *
223
+ * @param {string} data
224
+ * @returns {string | undefined}
225
+ */
226
+ function decodePrintable(data) {
227
+ const parsed = parseKey(data);
228
+ if (!parsed) return undefined;
229
+ const modifier = parsed.modifier & ~LOCK_MASK;
230
+ if ((modifier & ~MODIFIER_SHIFT) !== 0) return undefined;
231
+ let codepoint = parsed.codepoint;
232
+ if (modifier & MODIFIER_SHIFT) {
233
+ if (parsed.shifted !== undefined) codepoint = parsed.shifted;
234
+ else if (codepoint >= 97 && codepoint <= 122) codepoint -= 32;
235
+ }
236
+ codepoint = normalize(codepoint);
237
+ if (!Number.isFinite(codepoint) || codepoint < 32) return undefined;
238
+ if (codepoint >= KITTY_FUNCTIONAL_START && codepoint <= KITTY_FUNCTIONAL_END) return undefined;
239
+ try {
240
+ return String.fromCodePoint(codepoint);
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Whether raw input is the printable character, in any encoding: the byte
248
+ * itself (legacy), or a codepoint sequence with no modifier or Shift.
249
+ *
250
+ * @param {string} data raw bytes from the terminal
251
+ * @param {string} char one printable character
252
+ * @returns {boolean}
253
+ */
254
+ export function matchesPrintable(data, char) {
255
+ if (data === char) return true;
256
+ return decodePrintable(data) === char;
257
+ }
@@ -41,6 +41,9 @@ const ANTIGRAVITY_OAUTH_SOURCES = [
41
41
  join(homedir(), ".pi", "agent", "npm", "node_modules", "pi-antigravity", "dist", "auth", "oauth.js"),
42
42
  ];
43
43
 
44
+ /** Renewed Google access tokens live only in this process, until shortly before expiry. */
45
+ const renewedAccessTokens = new Map();
46
+
44
47
  /**
45
48
  * Recover the two `atob("..." + "...")` literals from the installed package.
46
49
  *
@@ -89,11 +92,15 @@ export function resolveOAuthClient(options = {}) {
89
92
  }
90
93
 
91
94
  /**
92
- * @param {{ client?: { clientId: string, clientSecret: string }, fetchFn?: typeof fetch, timeoutMs?: number }} [options]
95
+ * Describe the OAuth client a refresh used, resolved the same way the refresh
96
+ * resolved it: a description of the environment's client while the request went
97
+ * out with the installed package's would name the wrong credential source.
98
+ *
99
+ * @param {{ client?: { clientId: string, clientSecret: string }, env?: Record<string, string | undefined>, sourcePath?: string }} [options]
93
100
  * @returns {string}
94
101
  */
95
102
  export function describeOAuthClient(options = {}) {
96
- const client = options.client ?? resolveOAuthClient();
103
+ const client = options.client ?? resolveOAuthClient({ env: options.env, sourcePath: options.sourcePath });
97
104
  return client.source === "env"
98
105
  ? "client from ANTIGRAVITY_CLIENT_ID/SECRET"
99
106
  : client.source === "pi-antigravity"
@@ -108,12 +115,16 @@ export function describeOAuthClient(options = {}) {
108
115
  * fetchFn?: typeof fetch,
109
116
  * timeoutMs?: number,
110
117
  * now?: number,
118
+ * env?: Record<string, string | undefined>,
119
+ * sourcePath?: string,
111
120
  * }} [options]
112
121
  * @returns {Promise<{ access: string, refresh: string, expires: number } | { error: string }>}
113
122
  */
114
123
  export async function refreshAccessToken(refreshToken, options = {}) {
115
124
  if (!refreshToken) return { error: "no refresh token available" };
116
- const client = options.client ?? resolveOAuthClient();
125
+ // The caller's environment and package source decide the client. Keep these
126
+ // resolution inputs aligned with describeOAuthClient for an accurate failure.
127
+ const client = options.client ?? resolveOAuthClient({ env: options.env, sourcePath: options.sourcePath });
117
128
 
118
129
  const response = await requestJson(GOOGLE_TOKEN_URL, {
119
130
  method: "POST",
@@ -152,12 +163,30 @@ export async function refreshAccessToken(refreshToken, options = {}) {
152
163
  * now?: number,
153
164
  * bufferMs?: number,
154
165
  * enabled?: boolean,
166
+ * env?: Record<string, string | undefined>,
167
+ * sourcePath?: string,
168
+ * cacheKey?: string,
155
169
  * }} [options]
156
170
  * @returns {Promise<{ ok: boolean, refreshed: boolean, error?: string }>}
157
171
  */
158
172
  export async function ensureFreshAccessToken(account, options = {}) {
159
173
  const now = options.now ?? Date.now();
160
174
  const bufferMs = options.bufferMs ?? 5 * 60 * 1000;
175
+ const cacheKey = options.cacheKey;
176
+ if (cacheKey) {
177
+ const cached = renewedAccessTokens.get(cacheKey);
178
+ if (cached && cached.expiresAtMs - bufferMs > now) {
179
+ account.access = cached.access;
180
+ account.expiresAtMs = cached.expiresAtMs;
181
+ return { ok: true, refreshed: true };
182
+ }
183
+ if (cached) {
184
+ clearTimeout(cached.timer);
185
+ cached.access = "";
186
+ renewedAccessTokens.delete(cacheKey);
187
+ }
188
+ }
189
+
161
190
  const expiring = !account.expiresAtMs || account.expiresAtMs - bufferMs <= now;
162
191
  if (!expiring) return { ok: true, refreshed: false };
163
192
  if (options.enabled === false) return { ok: Boolean(account.access), refreshed: false, error: "refresh disabled" };
@@ -169,5 +198,16 @@ export async function ensureFreshAccessToken(account, options = {}) {
169
198
  account.access = result.access;
170
199
  account.refresh = result.refresh;
171
200
  account.expiresAtMs = result.expires;
201
+ if (cacheKey) {
202
+ const entry = { access: result.access, expiresAtMs: result.expires, timer: null };
203
+ const clearAfterMs = Math.max(1, result.expires - bufferMs - now);
204
+ entry.timer = setTimeout(() => {
205
+ if (renewedAccessTokens.get(cacheKey) !== entry) return;
206
+ entry.access = "";
207
+ renewedAccessTokens.delete(cacheKey);
208
+ }, clearAfterMs);
209
+ entry.timer.unref?.();
210
+ renewedAccessTokens.set(cacheKey, entry);
211
+ }
172
212
  return { ok: true, refreshed: true };
173
213
  }