@llblab/pi-kit 0.25.0 → 0.26.0

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 (104) hide show
  1. package/BACKLOG.md +5 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +9 -7
  4. package/node_modules/@llblab/pi-actors/AGENTS.md +2 -0
  5. package/node_modules/@llblab/pi-actors/CHANGELOG.md +4 -1
  6. package/node_modules/@llblab/pi-actors/LICENSE +21 -0
  7. package/node_modules/@llblab/pi-actors/README.md +1 -1
  8. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +1 -1
  9. package/node_modules/@llblab/pi-actors/package.json +4 -3
  10. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +6 -3
  11. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +2 -1
  12. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +8 -0
  13. package/node_modules/@llblab/pi-claude-usage/README.md +48 -3
  14. package/node_modules/@llblab/pi-claude-usage/index.ts +8 -1159
  15. package/node_modules/@llblab/pi-claude-usage/lib/extension.ts +30 -0
  16. package/node_modules/@llblab/pi-claude-usage/lib/fast.ts +24 -0
  17. package/node_modules/@llblab/pi-claude-usage/lib/query.ts +146 -0
  18. package/node_modules/@llblab/pi-claude-usage/lib/status-format.ts +297 -0
  19. package/node_modules/@llblab/pi-claude-usage/lib/status.ts +366 -0
  20. package/node_modules/@llblab/pi-claude-usage/lib/telegram.ts +44 -0
  21. package/node_modules/@llblab/pi-claude-usage/lib/usage-store.ts +221 -0
  22. package/node_modules/@llblab/pi-claude-usage/lib/usage.ts +128 -0
  23. package/node_modules/@llblab/pi-claude-usage/package.json +9 -5
  24. package/node_modules/@llblab/pi-clean-room/AGENTS.md +1 -0
  25. package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +5 -0
  26. package/node_modules/@llblab/pi-clean-room/LICENSE +21 -0
  27. package/node_modules/@llblab/pi-clean-room/README.md +1 -1
  28. package/node_modules/@llblab/pi-clean-room/package.json +3 -2
  29. package/node_modules/@llblab/pi-codex-usage/AGENTS.md +9 -6
  30. package/node_modules/@llblab/pi-codex-usage/BACKLOG.md +2 -1
  31. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +17 -0
  32. package/node_modules/@llblab/pi-codex-usage/README.md +75 -17
  33. package/node_modules/@llblab/pi-codex-usage/index.ts +8 -1602
  34. package/node_modules/@llblab/pi-codex-usage/lib/extension.ts +25 -0
  35. package/node_modules/@llblab/pi-codex-usage/lib/fast.ts +23 -0
  36. package/node_modules/@llblab/pi-codex-usage/lib/query.ts +368 -0
  37. package/node_modules/@llblab/pi-codex-usage/lib/status-format.ts +347 -0
  38. package/node_modules/@llblab/pi-codex-usage/lib/status.ts +435 -0
  39. package/node_modules/@llblab/pi-codex-usage/lib/telegram.ts +45 -0
  40. package/node_modules/@llblab/pi-codex-usage/lib/usage-store.ts +229 -0
  41. package/node_modules/@llblab/pi-codex-usage/lib/usage.ts +425 -0
  42. package/node_modules/@llblab/pi-codex-usage/package.json +11 -6
  43. package/node_modules/@llblab/pi-command-fast/AGENTS.md +7 -0
  44. package/node_modules/@llblab/pi-command-fast/BACKLOG.md +9 -0
  45. package/node_modules/@llblab/pi-command-fast/CHANGELOG.md +7 -0
  46. package/node_modules/@llblab/pi-command-fast/LICENSE +21 -0
  47. package/node_modules/@llblab/pi-command-fast/README.md +42 -0
  48. package/node_modules/@llblab/pi-command-fast/dist/command.d.ts +8 -0
  49. package/node_modules/@llblab/pi-command-fast/dist/command.js +52 -0
  50. package/node_modules/@llblab/pi-command-fast/dist/index.d.ts +3 -0
  51. package/node_modules/@llblab/pi-command-fast/dist/index.js +3 -0
  52. package/node_modules/@llblab/pi-command-fast/dist/models-json.d.ts +10 -0
  53. package/node_modules/@llblab/pi-command-fast/dist/models-json.js +81 -0
  54. package/node_modules/@llblab/pi-command-fast/package.json +49 -0
  55. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
  56. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -1
  57. package/node_modules/@llblab/pi-grow-loop/LICENSE +21 -0
  58. package/node_modules/@llblab/pi-grow-loop/README.md +1 -1
  59. package/node_modules/@llblab/pi-grow-loop/package.json +3 -2
  60. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -5
  61. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -2
  62. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +7 -1
  63. package/node_modules/@llblab/pi-state-flow/LICENSE +21 -0
  64. package/node_modules/@llblab/pi-state-flow/README.md +5 -5
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +1 -1
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +1 -1
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +238 -46
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +16 -5
  70. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +10 -1
  71. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +60 -1
  72. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +6 -0
  73. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +8 -5
  74. package/node_modules/@llblab/pi-state-flow/dist/package.json +10 -9
  75. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -7
  76. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
  77. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +4 -2
  78. package/node_modules/@llblab/pi-state-flow/docs/usage.md +14 -13
  79. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +2 -2
  80. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +221 -46
  81. package/node_modules/@llblab/pi-state-flow/lib/git.ts +14 -5
  82. package/node_modules/@llblab/pi-state-flow/lib/session.ts +52 -1
  83. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +7 -5
  84. package/node_modules/@llblab/pi-state-flow/package.json +10 -9
  85. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  86. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  87. package/node_modules/jsonc-parser/README.md +364 -0
  88. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  89. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  90. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  91. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  92. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  93. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  94. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  95. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  96. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  97. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  98. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  99. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  100. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  101. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  102. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  103. package/node_modules/jsonc-parser/package.json +37 -0
  104. package/package.json +7 -7
@@ -0,0 +1,425 @@
1
+ /** Domain: quota report. Owns: normalization, parsing and selection. Excludes: provider I/O and display. */
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ const SECOND_MS = 1000;
4
+ const CODEX_USAGE_LIMIT_ID = "codex";
5
+ type PiModel = NonNullable<ExtensionContext["model"]>;
6
+ export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
7
+ export function isOpenAICodexModel(model: Pick<PiModel, "provider"> | undefined): boolean {
8
+ return model?.provider === "openai-codex";
9
+ }
10
+ export type UsageSource = "pi-auth" | "codex-app-server";
11
+ export type UsageQueryError = { source: UsageSource; message: string; cause?: unknown };
12
+ export type CodexUsageReport = {
13
+ snapshots: NormalizedRateLimitSnapshot[];
14
+ credits?: NormalizedCreditUsage;
15
+ };
16
+
17
+ export type NormalizedCreditUsage = {
18
+ remainingPercent: number;
19
+ resetAt?: number;
20
+ };
21
+
22
+ export type NormalizedRateLimitSnapshot = {
23
+ limitId: string;
24
+ primary?: NormalizedRateLimitWindow;
25
+ secondary?: NormalizedRateLimitWindow;
26
+ };
27
+
28
+ export type NormalizedRateLimitWindow = {
29
+ usedPercent: number;
30
+ resetAt?: number;
31
+ };
32
+
33
+ export type RateLimitStatusPayload = {
34
+ rate_limit?: unknown;
35
+ additional_rate_limits?: unknown;
36
+ credits?: unknown;
37
+ spend_control?: unknown;
38
+ };
39
+
40
+ type BackendRateLimitDetails = {
41
+ primary_window?: unknown;
42
+ secondary_window?: unknown;
43
+ };
44
+
45
+ type BackendWindowSnapshot = {
46
+ used_percent?: unknown;
47
+ reset_at?: unknown;
48
+ resets_at?: unknown;
49
+ reset_time?: unknown;
50
+ end_time?: unknown;
51
+ ends_at?: unknown;
52
+ expires_at?: unknown;
53
+ reset_after_seconds?: unknown;
54
+ };
55
+
56
+ export type AppServerRateLimitResponse = {
57
+ rateLimits?: unknown;
58
+ };
59
+
60
+ type AppServerRateLimitSnapshot = {
61
+ limitId?: unknown;
62
+ primary?: unknown;
63
+ secondary?: unknown;
64
+ };
65
+
66
+ type AppServerWindowSnapshot = {
67
+ usedPercent?: unknown;
68
+ resetAt?: unknown;
69
+ resetsAt?: unknown;
70
+ resetTime?: unknown;
71
+ endTime?: unknown;
72
+ endsAt?: unknown;
73
+ expiresAt?: unknown;
74
+ resetAfterSeconds?: unknown;
75
+ };
76
+
77
+
78
+ export function normalizeBackendPayload(
79
+ payload: RateLimitStatusPayload,
80
+ _capturedAt: number,
81
+ _source: UsageSource,
82
+ ): CodexUsageReport {
83
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
84
+ const primarySnapshot = normalizeBackendSnapshot(
85
+ CODEX_USAGE_LIMIT_ID,
86
+ payload.rate_limit,
87
+ _capturedAt,
88
+ );
89
+ if (primarySnapshot) snapshots.push(primarySnapshot);
90
+
91
+ const credits = normalizeBackendCredits(payload, _capturedAt);
92
+ if (snapshots.length === 0 && !credits) {
93
+ throw new Error(
94
+ "Codex usage endpoint returned no displayable rate-limit windows or credits.",
95
+ );
96
+ }
97
+ return credits ? { snapshots, credits } : { snapshots };
98
+ }
99
+
100
+ function normalizeBackendCredits(
101
+ payload: RateLimitStatusPayload,
102
+ capturedAt: number,
103
+ ): NormalizedCreditUsage | undefined {
104
+ const spendControl = payload.spend_control as
105
+ | Record<string, unknown>
106
+ | undefined;
107
+ const individualLimit = spendControl?.individual_limit as
108
+ | Record<string, unknown>
109
+ | undefined;
110
+ const credits = payload.credits as Record<string, unknown> | undefined;
111
+ const limit = asNumber(individualLimit?.limit);
112
+ const used = asNumber(individualLimit?.used);
113
+ const remaining = asNumber(individualLimit?.remaining);
114
+ const resetAt = asResetTime(
115
+ [
116
+ individualLimit?.reset_at,
117
+ individualLimit?.resets_at,
118
+ individualLimit?.reset_time,
119
+ spendControl?.reset_at,
120
+ spendControl?.resets_at,
121
+ credits?.reset_at,
122
+ credits?.resets_at,
123
+ ],
124
+ individualLimit?.reset_after_seconds ?? spendControl?.reset_after_seconds,
125
+ capturedAt,
126
+ );
127
+ if (
128
+ limit === undefined ||
129
+ used === undefined ||
130
+ remaining === undefined ||
131
+ limit <= 0
132
+ )
133
+ return undefined;
134
+ const remainingPercent = Math.min(
135
+ 100,
136
+ Math.max(0, (remaining / limit) * 100),
137
+ );
138
+ return resetAt === undefined
139
+ ? { remainingPercent }
140
+ : { remainingPercent, resetAt };
141
+ }
142
+
143
+ function normalizeBackendSnapshot(
144
+ limitId: string,
145
+ rateLimit: unknown,
146
+ capturedAt: number,
147
+ ): NormalizedRateLimitSnapshot | undefined {
148
+ if (rateLimit === null || rateLimit === undefined) return undefined;
149
+ const details = assertObject(
150
+ rateLimit,
151
+ "rate limit",
152
+ ) as BackendRateLimitDetails;
153
+ const primary = normalizeBackendWindow(details.primary_window, capturedAt);
154
+ const secondary = normalizeBackendWindow(
155
+ details.secondary_window,
156
+ capturedAt,
157
+ );
158
+ if (!primary && !secondary) return undefined;
159
+ return { limitId, primary, secondary };
160
+ }
161
+
162
+ function normalizeBackendWindow(
163
+ value: unknown,
164
+ capturedAt: number,
165
+ ): NormalizedRateLimitWindow | undefined {
166
+ if (value === null || value === undefined) return undefined;
167
+ const window = assertObject(
168
+ value,
169
+ "rate-limit window",
170
+ ) as BackendWindowSnapshot;
171
+ const usedPercent = asNumber(window.used_percent);
172
+ if (usedPercent === undefined) return undefined;
173
+ const resetAt = asResetTime(
174
+ [
175
+ window.reset_at,
176
+ window.resets_at,
177
+ window.reset_time,
178
+ window.end_time,
179
+ window.ends_at,
180
+ window.expires_at,
181
+ ],
182
+ window.reset_after_seconds,
183
+ capturedAt,
184
+ );
185
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
186
+ }
187
+
188
+ export function normalizeAppServerResponse(
189
+ response: AppServerRateLimitResponse,
190
+ _capturedAt: number,
191
+ ): CodexUsageReport {
192
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
193
+ const addSnapshot = (raw: unknown, fallbackId: string) => {
194
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId, _capturedAt);
195
+ if (!snapshot) return;
196
+ const existingIndex = snapshots.findIndex(
197
+ (item) => item.limitId === snapshot.limitId,
198
+ );
199
+ if (existingIndex >= 0)
200
+ snapshots[existingIndex] = mergeSnapshot(
201
+ snapshots[existingIndex],
202
+ snapshot,
203
+ );
204
+ else snapshots.push(snapshot);
205
+ };
206
+
207
+ if (Array.isArray(response.rateLimits)) {
208
+ for (const item of response.rateLimits) addSnapshot(item, "codex");
209
+ } else {
210
+ addSnapshot(response.rateLimits, "codex");
211
+ }
212
+ if (snapshots.length === 0) {
213
+ throw new Error(
214
+ "codex app-server returned no displayable rate-limit windows.",
215
+ );
216
+ }
217
+
218
+ return { snapshots };
219
+ }
220
+
221
+ function normalizeAppServerSnapshot(
222
+ raw: unknown,
223
+ fallbackId: string,
224
+ capturedAt: number,
225
+ ): NormalizedRateLimitSnapshot | undefined {
226
+ if (raw === null || raw === undefined) return undefined;
227
+ const snapshot = assertObject(
228
+ raw,
229
+ "app-server rate-limit snapshot",
230
+ ) as AppServerRateLimitSnapshot;
231
+ const limitId = asString(snapshot.limitId) ?? fallbackId;
232
+ if (normalizedUsageKey(limitId) !== CODEX_USAGE_LIMIT_ID) return undefined;
233
+ const primary = normalizeAppServerWindow(snapshot.primary, capturedAt);
234
+ const secondary = normalizeAppServerWindow(snapshot.secondary, capturedAt);
235
+ if (!primary && !secondary) return undefined;
236
+ return { limitId, primary, secondary };
237
+ }
238
+
239
+ function normalizeAppServerWindow(
240
+ value: unknown,
241
+ capturedAt: number,
242
+ ): NormalizedRateLimitWindow | undefined {
243
+ if (value === null || value === undefined) return undefined;
244
+ const window = assertObject(
245
+ value,
246
+ "app-server rate-limit window",
247
+ ) as AppServerWindowSnapshot;
248
+ const usedPercent = asNumber(window.usedPercent);
249
+ if (usedPercent === undefined) return undefined;
250
+ const resetAt = asResetTime(
251
+ [
252
+ window.resetAt,
253
+ window.resetsAt,
254
+ window.resetTime,
255
+ window.endTime,
256
+ window.endsAt,
257
+ window.expiresAt,
258
+ ],
259
+ window.resetAfterSeconds,
260
+ capturedAt,
261
+ );
262
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
263
+ }
264
+
265
+ function mergeSnapshot(
266
+ left: NormalizedRateLimitSnapshot,
267
+ right: NormalizedRateLimitSnapshot,
268
+ ): NormalizedRateLimitSnapshot {
269
+ return {
270
+ limitId: right.limitId || left.limitId,
271
+ primary: right.primary ?? left.primary,
272
+ secondary: right.secondary ?? left.secondary,
273
+ };
274
+ }
275
+
276
+
277
+ export function isUsageUnavailable(errors: UsageQueryError[]): boolean {
278
+ return errors.length > 0 && errors.every(isUnavailableError);
279
+ }
280
+
281
+ function isUnavailableError(error: UsageQueryError): boolean {
282
+ const message = error.message.toLowerCase();
283
+ return (
284
+ message.includes("no pi openai codex subscription auth") ||
285
+ message.includes("no displayable rate-limit windows") ||
286
+ message.includes("returned no displayable rate-limit windows") ||
287
+ message.includes("returned 401") ||
288
+ message.includes("returned 403") ||
289
+ message.includes("unauthorized") ||
290
+ message.includes("forbidden") ||
291
+ message.includes("subscription") ||
292
+ message.includes("no active plan") ||
293
+ message.includes("plan unavailable") ||
294
+ message.includes("quota unavailable") ||
295
+ message.includes("rate limits unavailable")
296
+ );
297
+ }
298
+
299
+ function normalizedUsageKey(value: string | undefined): string | undefined {
300
+ const key = value
301
+ ?.toLowerCase()
302
+ .replace(/[^a-z0-9]+/g, "-")
303
+ .replace(/^-+|-+$/g, "");
304
+ return key || undefined;
305
+ }
306
+
307
+
308
+ export function canReuseCachedReport(
309
+ report: CodexUsageReport,
310
+ model: CodexUsageModel | undefined,
311
+ ): boolean {
312
+ return (
313
+ selectActiveUsageSnapshot(report, model) !== undefined ||
314
+ report.credits !== undefined
315
+ );
316
+ }
317
+
318
+ export function isFullyAvailableReport(
319
+ report: CodexUsageReport,
320
+ model?: CodexUsageModel,
321
+ ): boolean {
322
+ const snapshot = selectActiveUsageSnapshot(report, model);
323
+ const windows = [snapshot?.primary, snapshot?.secondary].filter(
324
+ (window): window is NormalizedRateLimitWindow => window !== undefined,
325
+ );
326
+ return (
327
+ windows.length > 0 &&
328
+ windows.every((window) => clampPercent(window.usedPercent) === 0)
329
+ );
330
+ }
331
+
332
+
333
+ export function selectActiveUsageSnapshot(
334
+ report: CodexUsageReport,
335
+ _model: CodexUsageModel | undefined,
336
+ ): NormalizedRateLimitSnapshot | undefined {
337
+ return selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID);
338
+ }
339
+
340
+ export function selectUsageSnapshot(
341
+ report: CodexUsageReport,
342
+ limitId: string,
343
+ ): NormalizedRateLimitSnapshot | undefined {
344
+ const normalizedLimitId = normalizedUsageKey(limitId);
345
+ return report.snapshots.find(
346
+ (snapshot) => normalizedUsageKey(snapshot.limitId) === normalizedLimitId,
347
+ );
348
+ }
349
+
350
+ export function clampPercent(value: number): number {
351
+ if (!Number.isFinite(value)) return 0;
352
+ return Math.min(100, Math.max(0, value));
353
+ }
354
+
355
+
356
+ export function parseJsonObject(
357
+ text: string,
358
+ description: string,
359
+ ): Record<string, unknown> {
360
+ let parsed: unknown;
361
+ try {
362
+ parsed = JSON.parse(text) as unknown;
363
+ } catch (error) {
364
+ throw new Error(
365
+ `${description} was not valid JSON: ${errorMessage(error)}`,
366
+ );
367
+ }
368
+ return assertObject(parsed, description);
369
+ }
370
+
371
+ export function assertObject(
372
+ value: unknown,
373
+ description: string,
374
+ ): Record<string, unknown> {
375
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
376
+ throw new Error(`${description} was not an object.`);
377
+ }
378
+ return value as Record<string, unknown>;
379
+ }
380
+
381
+ function asString(value: unknown): string | undefined {
382
+ return typeof value === "string" ? value : undefined;
383
+ }
384
+
385
+ function asNumber(value: unknown): number | undefined {
386
+ if (typeof value === "number" && Number.isFinite(value)) return value;
387
+ if (typeof value === "string" && value.trim()) {
388
+ const parsed = Number(value);
389
+ return Number.isFinite(parsed) ? parsed : undefined;
390
+ }
391
+ return undefined;
392
+ }
393
+
394
+ function asResetTime(
395
+ absoluteValues: unknown[],
396
+ relativeSeconds: unknown,
397
+ capturedAt: number,
398
+ ): number | undefined {
399
+ for (const value of absoluteValues) {
400
+ const timestamp = asTimestampMs(value);
401
+ if (timestamp !== undefined) return timestamp;
402
+ }
403
+ const seconds = asNumber(relativeSeconds);
404
+ if (seconds === undefined || seconds < 0) return undefined;
405
+ return capturedAt + seconds * SECOND_MS;
406
+ }
407
+
408
+ function asTimestampMs(value: unknown): number | undefined {
409
+ if (typeof value === "number" && Number.isFinite(value)) {
410
+ if (value <= 0) return undefined;
411
+ return value < 10_000_000_000 ? value * SECOND_MS : value;
412
+ }
413
+ if (typeof value === "string" && value.trim()) {
414
+ const numeric = Number(value);
415
+ if (Number.isFinite(numeric)) return asTimestampMs(numeric);
416
+ const parsed = Date.parse(value);
417
+ return Number.isFinite(parsed) ? parsed : undefined;
418
+ }
419
+ return undefined;
420
+ }
421
+
422
+
423
+ export function errorMessage(error: unknown): string {
424
+ return error instanceof Error ? error.message : String(error);
425
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
- "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits",
5
+ "description": "Pi extension for Codex subscription usage and an optional Fast toggle",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi-extension",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "scripts": {
28
28
  "check": "node --experimental-strip-types -e \"await import('./index.ts'); console.log('pi-codex-usage: extension import ok')\"",
29
- "test": "node --experimental-strip-types --test test/*.test.ts",
29
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
30
30
  "typecheck": "tsc --noEmit",
31
31
  "audit": "npm audit --omit=peer",
32
32
  "pack:dry": "npm pack --dry-run",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "files": [
36
36
  "index.ts",
37
+ "lib/",
37
38
  "README.md",
38
39
  "AGENTS.md",
39
40
  "BACKLOG.md",
@@ -48,13 +49,17 @@
48
49
  "image": "https://github.com/llblab/pi-codex-usage/raw/main/banner.jpg"
49
50
  },
50
51
  "peerDependencies": {
51
- "@earendil-works/pi-agent-core": "*",
52
- "@earendil-works/pi-ai": "*",
53
- "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-agent-core": ">=1.0.0",
53
+ "@earendil-works/pi-ai": ">=1.0.0",
54
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
54
55
  "@sinclair/typebox": "*"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/node": "latest",
59
+ "jsonc-parser": "^3.3.1",
58
60
  "typescript": "latest"
61
+ },
62
+ "dependencies": {
63
+ "@llblab/pi-command-fast": "^0.1.0"
59
64
  }
60
65
  }
@@ -0,0 +1,7 @@
1
+ # Agent Notes
2
+
3
+ - `Pi baseline`: All declared Pi peers require ≥1.0.0; keep lockfile identity aligned and verify packed consumers offline. Do not claim compatibility with earlier hosts.
4
+ - `Library only`: No Pi extension factory, Pi resource metadata, provider semantics, model IDs, status, auth, or background work.
5
+ - `Command ownership`: Register from session_start; release on session_shutdown. A process-global versioned Symbol owns only a WeakMap of sessionManager identities, never current-provider or Fast state.
6
+ - `Model state`: models.json is authoritative. Enabled is exact configured value; OFF deletes the property. Always re-read; preserve user JSONC and unrelated data; use atomic replacement without quota mutexes.
7
+ - `Domain DAG`: index is exports only; command and models-json are independent owners. Tests mirror domain names. Pack ESM and declarations; test physically separate copies.
@@ -0,0 +1,9 @@
1
+ # Backlog
2
+
3
+ ## Concurrent validation gate
4
+
5
+ - Telegram's feature owner must rebuild and validate its current tree after parallel edits settle. Pi 1.0.0 compatibility passed for a consistent isolated source snapshot (`8b92be0f6679c6c1deb86d32f4a9a66cd42a1defbab2d05710845c2191601da7`); later journal edits are not covered by that evidence. This is not a request to take over unrelated Telegram feature work, reconnect transport, or reload the operator session.
6
+
7
+ ## Release gate
8
+
9
+ - Publish 0.1.0 before dependent usage releases, only with explicit authorization; consumers currently use local `file:` dependencies for tests. Before their publication, restore registry `^0.1.0` dependencies and regenerate/validate registry locks and packed artifacts.
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - Requires Pi ≥1.0.0 for a consistent host baseline across the library and usage consumers.
6
+ - Added session-scoped provider registration for exactly one argument-free `/fast`, including duplicate installed library copies and shutdown/reload reset.
7
+ - Added generic per-model JSONC override reads and atomic toggles using Pi's canonical models.json path. No provider request logic or Pi extension entrypoint.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 llblab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,42 @@
1
+ # pi-command-fast
2
+
3
+ `@llblab/pi-command-fast` is a shared library for coordinating one `/fast` command across multiple Pi extensions. **It is not a Pi extension and should not be installed as one.**
4
+
5
+ Register provider-level handlers; keep model capability checks inside the owning consumer, not this command registry. Consumers define Fast semantics and eligibility; each model owns its persisted override in Pi's `models.json`. The library itself has no model-ID allowlists, status, auth, provider transport, timers or separate config.
6
+
7
+ ```ts
8
+ import { registerFastProvider, toggleModelOverrideValue } from "@llblab/pi-command-fast";
9
+
10
+ let release: (() => void) | undefined;
11
+ pi.on("session_start", (_event, ctx) => {
12
+ release = registerFastProvider(pi, ctx, {
13
+ provider: "my-provider",
14
+ toggle(commandCtx) {
15
+ toggleModelOverrideValue({
16
+ provider: commandCtx.model!.provider,
17
+ id: commandCtx.model!.id,
18
+ property: "providerNativeField",
19
+ enabledValue: "providerNativeValue",
20
+ });
21
+ redrawExistingUsageStatus(commandCtx); // Consumer-owned, no quota request.
22
+ },
23
+ });
24
+ });
25
+ pi.on("session_shutdown", () => { release?.(); release = undefined; });
26
+ ```
27
+
28
+ Public API: `registerFastProvider`, `modelsJsonPath`, `isModelOverrideValue`, `toggleModelOverrideValue`. OFF deletes only the property. Reads fail closed; writes report concrete errors. Edits preserve JSONC, BOM, unrelated fields and object order, re-read before toggling, and replace via a sibling temporary file/atomic rename. This is corruption protection, not cross-process transaction isolation.
29
+
30
+ Duplicate installed library copies discover a versioned `Symbol.for` WeakMap keyed by `ctx.sessionManager`. Invocation resolves the current session and provider, independent of load order. Release all registrations on shutdown: Pi reload keeps the session manager but replaces command maps. Late releases cannot delete a fresh registry. The command handler never captures a current model or Fast boolean.
31
+
32
+ Requires Node ≥22.19.0 and Pi ≥1.0.0, with dynamic command registration. Pi 1.0.0 has no public unregister/conditional-visibility API: `/fast` stays listed while consumers are loaded and rejects unrelated providers. It is registered during session_start; Pi builds interactive autocomplete after extension binding. `npm run validate` builds ESM/declarations, tests duplicate-copy/session/config behavior, checks import, audit and package contents.
33
+
34
+ Packed-consumer integration in an actual Pi host (offline, synthetic credentials, no paid requests):
35
+
36
+ ```bash
37
+ npm run test:pi -- /path/to/pi-coding-agent fast.tgz codex-usage.tgz claude-usage.tgz
38
+ ```
39
+
40
+ This extracts both consumers, installs the packed library into separate physical dependency directories, and exercises single/dual load order, dispatch, arbitrary Codex model IDs and supported/rejected Claude model eligibility, session isolation, reload, restart, statuses and native HTTP assembly. All fetches are intercepted before network access; it does not prove provider-side Fast availability or real terminal rendering. The HTTP inspection needs Node ≥22.15 for zstd decompression when Pi compresses a request.
41
+
42
+ [Agent Notes](./AGENTS.md) · [Backlog](./BACKLOG.md) · [Changelog](./CHANGELOG.md)
@@ -0,0 +1,8 @@
1
+ /** Domain: Fast command. Owns: session-scoped provider dispatch. Excludes: UI status, auth and persisted Fast values. */
2
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ export interface FastProviderRegistration {
4
+ provider: string;
5
+ toggle(ctx: ExtensionCommandContext): Promise<void> | void;
6
+ }
7
+ /** Call from session_start; call the returned release function from session_shutdown. */
8
+ export declare function registerFastProvider(pi: ExtensionAPI, ctx: ExtensionContext, registration: FastProviderRegistration): () => void;
@@ -0,0 +1,52 @@
1
+ const KEY = Symbol.for("@llblab/pi-command-fast/registry/v1");
2
+ const globals = globalThis;
3
+ const registries = globals[KEY] ??= new WeakMap();
4
+ /** Call from session_start; call the returned release function from session_shutdown. */
5
+ export function registerFastProvider(pi, ctx, registration) {
6
+ const session = ctx.sessionManager;
7
+ let registry = registries.get(session);
8
+ if (!registry) {
9
+ registry = { providers: new Map() };
10
+ registries.set(session, registry);
11
+ try {
12
+ pi.registerCommand("fast", {
13
+ description: "Toggle Fast mode for the current provider/model",
14
+ getArgumentCompletions: () => null,
15
+ handler: async (args, invocation) => {
16
+ if (args.trim()) {
17
+ invocation.ui.notify("Usage: /fast", "warning");
18
+ return;
19
+ }
20
+ const provider = invocation.model?.provider;
21
+ const handler = provider && registries.get(invocation.sessionManager)?.providers.get(provider);
22
+ if (!handler) {
23
+ invocation.ui.notify("Fast mode is not supported for the current provider", "warning");
24
+ return;
25
+ }
26
+ try {
27
+ await handler.toggle(invocation);
28
+ }
29
+ catch (error) {
30
+ invocation.ui.notify(error instanceof Error ? error.message : String(error), "error");
31
+ }
32
+ },
33
+ });
34
+ }
35
+ catch (error) {
36
+ registries.delete(session);
37
+ throw error;
38
+ }
39
+ }
40
+ if (registry.providers.has(registration.provider))
41
+ throw new Error(`Fast provider already registered: ${registration.provider}`);
42
+ registry.providers.set(registration.provider, registration);
43
+ const owned = registry;
44
+ return () => {
45
+ if (registries.get(session) !== owned || owned.providers.get(registration.provider) !== registration)
46
+ return;
47
+ owned.providers.delete(registration.provider);
48
+ // Reload retains the sessionManager, but destroys all commands: reset after last release.
49
+ if (owned.providers.size === 0)
50
+ registries.delete(session);
51
+ };
52
+ }
@@ -0,0 +1,3 @@
1
+ /** Domain: public contract. Owns: library exports. Excludes: Pi extension entrypoint. */
2
+ export { registerFastProvider, type FastProviderRegistration } from "./command.js";
3
+ export { modelsJsonPath, isModelOverrideValue, toggleModelOverrideValue, type ModelOverrideTarget } from "./models-json.js";
@@ -0,0 +1,3 @@
1
+ /** Domain: public contract. Owns: library exports. Excludes: Pi extension entrypoint. */
2
+ export { registerFastProvider } from "./command.js";
3
+ export { modelsJsonPath, isModelOverrideValue, toggleModelOverrideValue } from "./models-json.js";
@@ -0,0 +1,10 @@
1
+ export interface ModelOverrideTarget {
2
+ provider: string;
3
+ id: string;
4
+ property: string;
5
+ enabledValue: string;
6
+ }
7
+ export declare function modelsJsonPath(): string;
8
+ export declare function isModelOverrideValue(target: ModelOverrideTarget, path?: string): boolean;
9
+ /** Re-read at every toggle; OFF is property absence, never a sentinel value. */
10
+ export declare function toggleModelOverrideValue(target: ModelOverrideTarget, path?: string): boolean;