@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.
- package/CHANGELOG.md +22 -0
- package/README.md +81 -39
- package/bin/piquota.js +12 -2
- package/extensions/quota-panel.ts +532 -184
- package/package.json +1 -1
- package/src/cache.js +128 -31
- package/src/cli/commands/omarchy.js +21 -3
- package/src/engine.js +98 -7
- package/src/local/pi-sessions.js +28 -1
- package/src/local/stats-worker.js +27 -0
- package/src/local/worker.js +157 -0
- package/src/model.js +39 -5
- package/src/omarchy/publish.js +9 -8
- package/src/overlay-keys.js +257 -0
- package/src/providers/antigravity-oauth.js +43 -3
- package/src/providers/antigravity.js +44 -5
- package/src/refresh.js +4 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
/**
|
package/src/omarchy/publish.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|