pi-hypercharm-provider 1.3.39 → 1.3.40

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/AGENTS.md CHANGED
@@ -23,6 +23,7 @@ When a model needs overrides, new properties, or corrections, edit the appropria
23
23
  | `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
24
24
  | `identity.ts` | Every identifier this extension registers into a shared pi surface (provider id, custom api name, status/widget keys, command, prism entry type, auth key + env var, config/cache file names). `tests/identity.test.ts` enforces namespacing, uniqueness, and disjointness from the official `@charmland/pi-hyper-provider` — never hardcode one of these elsewhere. |
25
25
  | `notify.ts` | Deduplicated warning sink: fetch/parse failures go to the session UI once one is active, stderr before that, never thrown. |
26
+ | `sync-policy.ts` | Quiet startup sync policy (TTL, key fingerprint, cross-process lock). Provider-agnostic and shared verbatim across pi-*-provider repos — keep it free of provider specifics. Also gates the footer's startup account prefetch (`hypercharm-account.json`). |
26
27
  | `prism.ts` | Hyper routing-header validation (sanitizer + persisted-entry re-validation). Pure module — no pi imports; exercised by `tests/prism.test.ts`. |
27
28
 
28
29
  ## Data Flow
@@ -64,8 +65,9 @@ Provider API ──fetch──► models.json ──apply──► patch.jso
64
65
  ## Tests
65
66
 
66
67
  `npm run check` runs `tsc --noEmit`, the dependency-free status smoke test, and
67
- the node:test suites (through `jiti/register` so the extension's extensionless
68
- TS imports resolve). `npm test` runs only the test suites.
68
+ the test suites: the node:test suites (through `jiti/register` so the
69
+ extension's extensionless TS imports resolve), then the vitest suites listed in
70
+ `vitest.config.ts`. `npm test` runs only the test suites.
69
71
 
70
72
  | File | Scope |
71
73
  |------|-------|
@@ -73,6 +75,8 @@ TS imports resolve). `npm test` runs only the test suites.
73
75
  | `tests/identity.test.ts` | Co-install invariant: namespaced + unique + disjoint from the official provider's reserved names |
74
76
  | `tests/prism.test.ts` | Prism header/label sanitization and persisted-entry validation |
75
77
  | `tests/notify.test.ts` | Warning dedupe, UI routing, stale-ctx stderr fallback |
78
+ | `tests/sync-policy.test.ts` | (vitest) Shared quiet-sync policy: TTL, fingerprint, backoff, lock — copied verbatim across pi-*-provider repos |
79
+ | `tests/quiet-startup-sync.test.ts` | (vitest) session_start integration: no-credential/no-fetch, TTL, lock, OAuth identity, /login force, shared account snapshot, UI-only account prefetch |
76
80
  | `tests/provider.integration.test.ts` | Real pi runtime (DefaultResourceLoader + createAgentSession + ExtensionRunner): embedded catalog, catalog hot-swap/cache/retention, deprecated grace window, warning surfacing, prism entry durability, and co-installation against `tests/fixtures/official-surface.ts` |
77
81
 
78
82
  The integration suite stubs `globalThis.fetch` and emits the session lifecycle
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.40
4
+
5
+ - Quiet startup sync: background `/v1/provider` revalidation runs only with credentials, only when the disk cache is older than 1 hour (`PI_PROVIDER_SYNC_TTL_MS`, `0` = always), missing, or fetched with different credentials, and only in one pi process at a time (lock file next to the cache). Failed refreshes back off for 5 minutes; sessions that skip adopt a cache refreshed by another process. A completed `/login` forces a revalidation.
6
+ - OAuth sessions are fingerprinted by the stored refresh token (not the rotating access token), so token refreshes don't trigger refetches. Only a salted sha256 fingerprint is stored (`cache/hypercharm-models.sync.json`); the cache file format is unchanged.
7
+ - The footer's session-start credits/team/devices prefetch now runs only in interactive sessions (not `pi -p` or child agents) and is shared across pi processes through `cache/hypercharm-account.json` (2-minute window). Turn-triggered balance polls are unchanged.
8
+ - Write the model cache atomically; `session_start` never awaits the network and swallows background failures.
9
+ - Add vitest coverage for the sync policy and the session_start integration (`npm test` runs it after the node:test suites).
10
+
3
11
  ## 1.3.36
4
12
 
5
13
  - Pin Pi SDK development dependencies to 1.0.0 while retaining wildcard host peers.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Run `bun run test:pi` for offline manifest, catalog, lifecycle and streaming che
24
24
  - **DeepSeek Native Thinking** — Uses the `deepseek` thinking format for Charm Hyper requests, with native `reasoning_effort` on models that publish levels
25
25
  - **OpenAI-compatible API** via Charm Hyper's `/v1/chat/completions` endpoint
26
26
  - **OAuth Device Flow** — sign in with `/login` under the `hypercharm` provider (independent of the official provider's `hyper` registration)
27
- - **Official Catalog Sync** from Charm's typed `/v1/provider` endpoint, matching `@charmland/pi-hyper-provider`
27
+ - **Official Catalog Sync** from Charm's typed `/v1/provider` endpoint, matching `@charmland/pi-hyper-provider` — refreshed quietly in the background, at most hourly and by one pi process at a time ([details](#model-sync))
28
28
  - **Reasoning Models** with provider-published on/off states and exact effort levels
29
29
  - **Attachment Support** for models the official catalog marks as attachment-capable
30
30
 
@@ -138,7 +138,11 @@ renders when the session-start or model-select credits fetch lands, and the
138
138
  session side joins it after the first HyperCharm turn completes. Selecting
139
139
  another provider hides it (`hideOnOtherProvider` defaults to `true`), the
140
140
  balance refreshes when the agent run fully settles, and sessions that never
141
- use HyperCharm make no status-related API calls:
141
+ use HyperCharm make no status-related API calls. The session-start prefetch
142
+ runs only in interactive sessions (never in `pi -p` or child agents) and is
143
+ shared across pi processes: one process polls and writes
144
+ `~/.pi/agent/cache/hypercharm-account.json`, and sessions started within the
145
+ next 2 minutes render that snapshot instead of polling again:
142
146
 
143
147
  ```
144
148
  ⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 29d
@@ -209,6 +213,7 @@ Get your API key from [hyper.charm.land](https://hyper.charm.land).
209
213
  | Variable | Required | Description |
210
214
  |----------|----------|-------------|
211
215
  | `HYPERCHARM_API_KEY` | No | Your Charm Hyper API key (fallback if not in auth.json) |
216
+ | `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
212
217
 
213
218
  ## Configuration
214
219
 
@@ -246,6 +251,25 @@ Model metadata matches Charm's official [`@charmland/pi-hyper-provider`](https:/
246
251
 
247
252
  Each entry carries the model's full level map, because `patch.json` replaces `thinkingLevelMap` wholesale rather than merging it.
248
253
 
254
+ ## Model sync
255
+
256
+ Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/hypercharm-models.json`,
257
+ falling back to the bundled `models.json`) and revalidates against `/v1/provider` in the background — only when:
258
+
259
+ - HyperCharm credentials are configured (no credentials, no request),
260
+ - the cache is older than 1 hour, missing, or was fetched with different credentials, and
261
+ - no other pi process is already refreshing it (a `hypercharm-models.lock` file next to the cache).
262
+
263
+ So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
264
+ session that skips the fetch still picks up a cache another process refreshed. A failed refresh is retried after
265
+ 5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start).
266
+ Only a hash fingerprint of the credential is stored (`hypercharm-models.sync.json`), never the credential itself.
267
+ For OAuth the fingerprint follows the stored refresh token rather than the short-lived access token, so routine
268
+ token refreshes do not trigger a refetch, while a completed `/login` always forces one.
269
+
270
+ The footer's session-start account prefetch uses the same policy with its own cache
271
+ (`hypercharm-account.json`, 2-minute window), and only in interactive sessions.
272
+
249
273
  ## Updating Models
250
274
 
251
275
  Run the update script to fetch the latest models from the HyperCharm API:
package/identity.ts CHANGED
@@ -27,6 +27,8 @@ export const PRISM_ENTRY_TYPE = `${PROVIDER_ID}-prism-route`;
27
27
 
28
28
  export const CONFIG_FILE_NAME = `${PROVIDER_ID}.json`;
29
29
  export const CACHE_FILE_NAME = `${PROVIDER_ID}-models.json`;
30
+ /** Cross-process account snapshot (balance/team/device expiry) shared by quiet startup sync. */
31
+ export const ACCOUNT_CACHE_FILE_NAME = `${PROVIDER_ID}-account.json`;
30
32
 
31
33
  /**
32
34
  * Every distinct namespaced identifier this extension registers or writes.
@@ -45,5 +47,6 @@ export const IDENTIFIERS: readonly string[] = Object.freeze([
45
47
  PRISM_ENTRY_TYPE,
46
48
  CONFIG_FILE_NAME,
47
49
  CACHE_FILE_NAME,
50
+ ACCOUNT_CACHE_FILE_NAME,
48
51
  ]),
49
52
  ]);
package/index.ts CHANGED
@@ -12,7 +12,12 @@
12
12
  *
13
13
  * Model resolution strategy: Stale-While-Revalidate
14
14
  * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
15
- * 2. Revalidate in background: live API /v1/models → merge with embedded → cache → hot-swap
15
+ * 2. Revalidate in background: live API /v1/provider → merge with embedded → cache → hot-swap
16
+ * Quiet startup sync (sync-policy.ts): only with credentials, only when the
17
+ * disk cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS), missing,
18
+ * or fetched under another credential identity (OAuth: the stored refresh
19
+ * token, since access tokens rotate), and only one pi process at a time
20
+ * (lock file next to the cache). A completed /login forces a revalidation.
16
21
  * 3. patch.json + custom-models.json applied on top of whichever source won
17
22
  *
18
23
  * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
@@ -40,7 +45,11 @@
40
45
  * everything the moment the active model belongs to another provider.
41
46
  * Credits/team are prefetched on session start or model select when a
42
47
  * HyperCharm model is active, so the first turn ends with data already
43
- * cached. The balance is polled again on pi's agent_settled event (fires
48
+ * cached. The session-start prefetch runs only with a UI (never in print
49
+ * mode or child agents) and is shared across pi processes: the result is
50
+ * written to cache/hypercharm-account.json and processes that start within
51
+ * ACCOUNT_SYNC_TTL_MS (2 min) of it render that snapshot instead of
52
+ * polling (same lock/fingerprint policy as the model sync). The balance is polled again on pi's agent_settled event (fires
44
53
  * only once no automatic retry,
45
54
  * compaction, or queued continuation can follow) — and nowhere else, so
46
55
  * sessions without HyperCharm turns make zero status-related API calls.
@@ -107,9 +116,11 @@
107
116
 
108
117
  import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
109
118
  import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
119
+ import type { OAuthCredentials } from "@earendil-works/pi-ai";
110
120
  import { Text } from "@earendil-works/pi-tui";
111
121
  import { USER_AGENT, loginHypercharm, refreshHypercharmToken } from "./oauth";
112
122
  import {
123
+ ACCOUNT_CACHE_FILE_NAME,
113
124
  API_KEY_PLACEHOLDER,
114
125
  API_NAME,
115
126
  CACHE_FILE_NAME,
@@ -149,6 +160,14 @@ import {
149
160
  import fs from "fs";
150
161
  import { hostname } from "os";
151
162
  import path from "path";
163
+ import {
164
+ acquireModelSync,
165
+ cacheChangedSinceLoad,
166
+ keyFingerprint,
167
+ markCacheLoaded,
168
+ syncMetaPath,
169
+ writeFileAtomic,
170
+ } from "./sync-policy";
152
171
 
153
172
  // ─── Types ────────────────────────────────────────────────────────────────────
154
173
 
@@ -388,7 +407,8 @@ function loadCachedModels(): JsonModel[] | null {
388
407
  function cacheModels(models: JsonModel[]): void {
389
408
  try {
390
409
  fs.mkdirSync(CACHE_DIR, { recursive: true });
391
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
410
+ // Atomic (tmp + rename): other pi processes may be reading the cache right now.
411
+ writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
392
412
  } catch (err) {
393
413
  // Non-fatal: the freshly fetched catalog still serves this session.
394
414
  notifier.warn(`Could not write the HyperCharm model cache to ${CACHE_PATH}: ${describeError(err)}.`);
@@ -455,6 +475,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
455
475
  }
456
476
 
457
477
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
478
+ markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
458
479
  const cached = loadCachedModels();
459
480
  if (!cached || cached.length === 0) return embeddedModels;
460
481
 
@@ -468,24 +489,77 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
468
489
  return cached;
469
490
  }
470
491
 
471
- async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
472
- if (!apiKey) return null;
473
- const liveModels = await fetchLiveModels(apiKey, signal);
474
- if (!liveModels || liveModels.length === 0) return null;
475
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
476
- cacheModels(merged);
477
- return merged;
492
+ /**
493
+ * Background revalidation, gated by the quiet startup sync policy
494
+ * (sync-policy.ts): no credentials → no fetch; fresh cache (TTL) → no fetch;
495
+ * another process already fetching → no fetch. When skipped, a cache that
496
+ * another process refreshed since we loaded it is adopted from disk instead.
497
+ * `force` bypasses the TTL (e.g. after /login); it is still single-flight.
498
+ *
499
+ * `identity` is the stable credential identity used only for presence and the
500
+ * fingerprint (see credentialIdentity); `apiKey` is the bearer token we fetch with.
501
+ */
502
+ async function revalidateModels(
503
+ apiKey: string | undefined,
504
+ identity: string | undefined,
505
+ embeddedModels: JsonModel[],
506
+ signal?: AbortSignal,
507
+ force = false,
508
+ ): Promise<JsonModel[] | null> {
509
+ const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey: apiKey ? identity : undefined, force });
510
+ if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
511
+ let merged: JsonModel[] | null = null;
512
+ try {
513
+ const liveModels = apiKey ? await fetchLiveModels(apiKey, signal) : null;
514
+ if (!liveModels || liveModels.length === 0) return null;
515
+ merged = mergeWithEmbedded(liveModels, embeddedModels);
516
+ cacheModels(merged);
517
+ return merged;
518
+ } finally {
519
+ lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
520
+ }
478
521
  }
479
522
 
480
523
  // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
481
524
 
482
525
  let cachedApiKey: string | undefined;
526
+ // Stable identity behind cachedApiKey for the quiet-sync fingerprints.
527
+ let cachedSyncIdentity: string | undefined;
483
528
  let revalidateAbort: AbortController | null = null;
484
529
 
530
+ const AUTH_PATH = path.join(getAgentDir(), "auth.json");
531
+
532
+ const oauthIdentity = (refresh: string): string => `oauth:${refresh}`;
533
+
534
+ /**
535
+ * Stable credential identity for the quiet-sync fingerprint. OAuth access
536
+ * tokens rotate on every token refresh, so fingerprinting the resolved key
537
+ * would look like an account change each time. For an OAuth entry in
538
+ * auth.json use its refresh token instead (it changes on /login); API keys —
539
+ * auth.json, --api-key, HYPERCHARM_API_KEY — are static, so use the key itself.
540
+ * Only ever hashed (sync-policy keyFingerprint), never stored.
541
+ */
542
+ function credentialIdentity(apiKey: string | undefined): string | undefined {
543
+ if (!apiKey) return undefined;
544
+ try {
545
+ const stored = JSON.parse(fs.readFileSync(AUTH_PATH, "utf8"))?.[PROVIDER_ID];
546
+ if (stored?.type === "oauth" && typeof stored.refresh === "string" && stored.refresh) {
547
+ return oauthIdentity(stored.refresh);
548
+ }
549
+ } catch {
550
+ // No/unreadable auth.json: the key resolved from elsewhere (env, --api-key).
551
+ }
552
+ return apiKey;
553
+ }
554
+
485
555
  async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
486
556
  cachedApiKey = await modelRegistry.getApiKeyForProvider(PROVIDER_ID) ?? undefined;
557
+ cachedSyncIdentity = credentialIdentity(cachedApiKey);
487
558
  }
488
559
 
560
+ // Set by the extension entry point: runs after a /login completes.
561
+ let onLoginComplete: ((credentials: OAuthCredentials) => void) | null = null;
562
+
489
563
  // ─── Status Display Configuration ──────────────────────────────────────────────
490
564
 
491
565
  const CONFIG_PATH = path.join(getAgentDir(), "extensions", "hypercharm.json");
@@ -698,6 +772,12 @@ let statusEpoch = 0;
698
772
  let lastCreditsFetchAt = 0;
699
773
  let creditsInFlight: Promise<void> | null = null;
700
774
  let metaFetched = false;
775
+ // Bumped on every successful /v1/credits read (lets the startup sync tell a
776
+ // fresh balance from one adopted off disk).
777
+ let creditsOkCount = 0;
778
+ // Absolute OAuth device-session expiry behind account.authDaysLeft (persisted
779
+ // in the account snapshot so other processes recompute days-left correctly).
780
+ let authExpiresAtMs: number | null = null;
701
781
 
702
782
  async function fetchJsonGet(url: string, apiKey: string, signal: AbortSignal | undefined, label: string): Promise<any | null> {
703
783
  try {
@@ -735,6 +815,7 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
735
815
  const rawBalance = typeof data?.balance === "number" ? data.balance : typeof data?.balance_usd === "number" ? data.balance_usd * 20 : undefined;
736
816
  if (typeof rawBalance === "number" && Number.isFinite(rawBalance)) {
737
817
  account.balance = rawBalance;
818
+ creditsOkCount += 1;
738
819
  }
739
820
  } finally {
740
821
  creditsInFlight = null;
@@ -764,13 +845,104 @@ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSign
764
845
  const own = devices.items.find((d: any) => typeof d?.name === "string" && d.name === `Pi (${hostname()})`);
765
846
  const expMs = own ? Date.parse(own.expires_at ?? "") : NaN;
766
847
  if (!Number.isNaN(expMs)) {
767
- account.authDaysLeft = Math.max(0, Math.ceil((expMs - Date.now()) / 86_400_000));
848
+ authExpiresAtMs = expMs;
849
+ account.authDaysLeft = daysUntil(expMs);
768
850
  }
769
851
  }
770
852
 
771
853
  if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
772
854
  }
773
855
 
856
+ function daysUntil(ms: number): number {
857
+ return Math.max(0, Math.ceil((ms - Date.now()) / 86_400_000));
858
+ }
859
+
860
+ // ─── Quiet Startup Account Sync ──────────────────────────────────────────────
861
+ // Every pi process with a UI used to poll /v1/credits + /v1/teams + /v1/devices
862
+ // on session_start. The startup prefetch now goes through the same
863
+ // cross-process policy as the model sync (sync-policy.ts), with its own cache:
864
+ // one process polls and writes cache/hypercharm-account.json, every other
865
+ // process starting within ACCOUNT_SYNC_TTL_MS renders that snapshot from disk.
866
+ // Turn-triggered polls (model_select, agent_settled, 402, /hypercharm-status
867
+ // refresh) are unchanged and stay throttled per process.
868
+
869
+ const ACCOUNT_CACHE_PATH = path.join(CACHE_DIR, ACCOUNT_CACHE_FILE_NAME);
870
+ /**
871
+ * Freshness window for the shared startup account snapshot. 2 minutes: long
872
+ * enough to collapse /new, /reload and fan-out bursts into one poll, short
873
+ * enough that a session started later shows a current balance. The balance
874
+ * also moves optimistically per turn and agent_settled reconciles it.
875
+ */
876
+ const ACCOUNT_SYNC_TTL_MS = 2 * 60 * 1000;
877
+
878
+ interface AccountSnapshot {
879
+ version: 1;
880
+ balance: number | null;
881
+ teamName: string | null;
882
+ authExpiresAt: number | null;
883
+ }
884
+
885
+ /** Render a snapshot another process wrote, if it belongs to the same credential identity. */
886
+ function adoptAccountSnapshot(identity: string): void {
887
+ try {
888
+ const meta = JSON.parse(fs.readFileSync(syncMetaPath(ACCOUNT_CACHE_PATH), "utf8"));
889
+ if (meta?.keyFingerprint !== keyFingerprint(identity)) return;
890
+ const snap = JSON.parse(fs.readFileSync(ACCOUNT_CACHE_PATH, "utf8")) as Partial<AccountSnapshot> | null;
891
+ if (!snap || typeof snap !== "object") return;
892
+ if (typeof snap.balance === "number" && Number.isFinite(snap.balance)) account.balance = snap.balance;
893
+ if (typeof snap.teamName === "string" && snap.teamName.trim()) account.teamName = snap.teamName.trim();
894
+ if (typeof snap.authExpiresAt === "number" && Number.isFinite(snap.authExpiresAt)) {
895
+ authExpiresAtMs = snap.authExpiresAt;
896
+ account.authDaysLeft = daysUntil(snap.authExpiresAt);
897
+ }
898
+ if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
899
+ } catch {
900
+ // Missing/corrupt snapshot: nothing to adopt.
901
+ }
902
+ }
903
+
904
+ function writeAccountSnapshot(): void {
905
+ const snap: AccountSnapshot = {
906
+ version: 1,
907
+ balance: account.balance,
908
+ teamName: account.teamName,
909
+ authExpiresAt: authExpiresAtMs,
910
+ };
911
+ try {
912
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
913
+ writeFileAtomic(ACCOUNT_CACHE_PATH, JSON.stringify(snap) + "\n");
914
+ } catch {
915
+ // Non-fatal: other processes just poll themselves.
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Session-start account prefetch: adopt the shared snapshot (synchronously,
921
+ * before the first await, so the caller can render it at once), then poll only
922
+ * if this process wins the lease. Callers gate on ctx.hasUI.
923
+ */
924
+ async function startupAccountSync(apiKey: string | undefined, identity: string | undefined, signal: AbortSignal): Promise<void> {
925
+ if (!apiKey || !identity) return;
926
+ adoptAccountSnapshot(identity);
927
+ const lease = acquireModelSync({
928
+ cachePath: ACCOUNT_CACHE_PATH,
929
+ apiKey: identity,
930
+ ttlMs: ACCOUNT_SYNC_TTL_MS,
931
+ isValidCache: (d) => d != null,
932
+ });
933
+ if (!lease) return;
934
+ let ok = false;
935
+ try {
936
+ const before = creditsOkCount;
937
+ metaFetched = false;
938
+ await Promise.all([refreshCredits(apiKey, signal, true), refreshAccountMeta(apiKey, signal)]);
939
+ ok = !signal.aborted && creditsOkCount > before;
940
+ if (ok) writeAccountSnapshot();
941
+ } finally {
942
+ lease.release(ok ? "fetched" : signal.aborted ? "aborted" : "failed");
943
+ }
944
+ }
945
+
774
946
  // ─── Status Rendering ─────────────────────────────────────────────────────────
775
947
 
776
948
  function currentProviderId(ctx: ExtensionContext): string | undefined {
@@ -882,6 +1054,7 @@ function resetStatusState(): void {
882
1054
  outOfCreditsNotified = false;
883
1055
  lastCreditsFetchAt = 0;
884
1056
  metaFetched = false;
1057
+ authExpiresAtMs = null;
885
1058
  }
886
1059
 
887
1060
  /** Commit per-turn pending capture into session state (after tees settle). */
@@ -1117,7 +1290,11 @@ function makeProviderConfig(models: JsonModel[] = currentModels) {
1117
1290
  streamSimple: streamHypercharm,
1118
1291
  oauth: {
1119
1292
  name: PROVIDER_DISPLAY_NAME,
1120
- login: (callbacks) => loginHypercharm(callbacks),
1293
+ login: async (callbacks) => {
1294
+ const credentials = await loginHypercharm(callbacks);
1295
+ onLoginComplete?.(credentials);
1296
+ return credentials;
1297
+ },
1121
1298
  refreshToken: (credentials, signal) => refreshHypercharmToken(credentials, signal),
1122
1299
  getApiKey: (credentials) => String(credentials.access ?? ""),
1123
1300
  },
@@ -1139,6 +1316,32 @@ export default function (pi: ExtensionAPI) {
1139
1316
 
1140
1317
  pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
1141
1318
 
1319
+ // Revalidate the catalog (quiet startup sync policy) and hot-swap on success.
1320
+ const syncModels = (epoch: number, signal: AbortSignal | undefined, force: boolean): Promise<void> =>
1321
+ revalidateModels(cachedApiKey, cachedSyncIdentity, embeddedModels, signal, force).then((freshBase) => {
1322
+ if (freshBase && epoch === statusEpoch && !signal?.aborted) {
1323
+ currentModels = buildModels(freshBase, customModels, patches);
1324
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1325
+ }
1326
+ });
1327
+
1328
+ // A completed /login means new credentials: adopt them and force a
1329
+ // revalidation (still single-flight). Deferred so pi persists the
1330
+ // credential first; never blocks or fails the login itself.
1331
+ onLoginComplete = (credentials) => {
1332
+ const access = String(credentials.access ?? "");
1333
+ if (!access) return;
1334
+ cachedApiKey = access;
1335
+ cachedSyncIdentity = typeof credentials.refresh === "string" && credentials.refresh ? oauthIdentity(credentials.refresh) : access;
1336
+ // New account: team/device metadata and the throttle start over.
1337
+ metaFetched = false;
1338
+ lastCreditsFetchAt = 0;
1339
+ const epoch = statusEpoch;
1340
+ setTimeout(() => {
1341
+ void syncModels(epoch, revalidateAbort?.signal, true).catch(() => {});
1342
+ }, 0);
1343
+ };
1344
+
1142
1345
  pi.registerCommand(STATUS_COMMAND, {
1143
1346
  description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
1144
1347
  handler: async (args, ctx) => {
@@ -1163,31 +1366,29 @@ export default function (pi: ExtensionAPI) {
1163
1366
  // over anything that touched provider registration during load.
1164
1367
  pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1165
1368
 
1166
- // A failure here used to vanish: no key resolved meant no refresh and no
1167
- // diagnostics. Surface it, then continue — without a key we serve the
1168
- // embedded/cached catalog.
1169
- resolveApiKey(ctx.modelRegistry)
1369
+ // Fire-and-forget: never await network here — startup must not block on
1370
+ // the provider API. Nothing is awaited and every rejection is swallowed.
1371
+ // A key-resolution failure is surfaced (it used to vanish), then we keep
1372
+ // serving the embedded/cached catalog.
1373
+ void resolveApiKey(ctx.modelRegistry)
1170
1374
  .catch((err) => {
1171
1375
  notifier.warn(`Unable to resolve HyperCharm credentials: ${describeError(err)} — serving cached/embedded models.`);
1172
1376
  })
1173
1377
  .then(() => {
1174
- // A session replacement while the key resolved invalidated the
1175
- // captured ctx (fast-resume, /new, /fork); nothing below may touch it.
1176
- if (epoch !== statusEpoch) return;
1177
- // Prefetch credits/team metadata only when a HyperCharm model is active
1178
- // (pi-neuralwatt also prefetches so the first turn ends with data, but
1179
- // gating here avoids API calls in sessions that never use the provider).
1180
- if (currentProviderId(ctx) === PROVIDER_ID) {
1181
- updateStatusAfter(refreshCredits(cachedApiKey, statusSignal, true), ctx);
1182
- updateStatusAfter(refreshAccountMeta(cachedApiKey, statusSignal), ctx);
1183
- }
1184
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1185
- if (freshBase && epoch === statusEpoch && !signal.aborted) {
1186
- currentModels = buildModels(freshBase, customModels, patches);
1187
- pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1378
+ // A session replacement while the key resolved invalidated the
1379
+ // captured ctx (fast-resume, /new, /fork); nothing below may touch it.
1380
+ if (epoch !== statusEpoch) return;
1381
+ // Prefetch credits/team metadata only when a HyperCharm model is
1382
+ // active and there is a UI to show it (print mode and child agents
1383
+ // never render the footer), deduped across pi processes.
1384
+ if (ctx.hasUI && currentProviderId(ctx) === PROVIDER_ID) {
1385
+ const accountSync = startupAccountSync(cachedApiKey, cachedSyncIdentity, statusSignal);
1386
+ updateStatus(ctx); // renders an adopted snapshot right away
1387
+ updateStatusAfter(accountSync.catch(() => {}), ctx);
1188
1388
  }
1189
- });
1190
- });
1389
+ return syncModels(epoch, signal, false);
1390
+ })
1391
+ .catch(() => { /* keep serving the stale catalog */ });
1191
1392
  });
1192
1393
 
1193
1394
  pi.on("model_select", (event, ctx) => {
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.3.39",
3
+ "version": "1.3.40",
4
4
  "description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "clean": "echo 'nothing to clean'",
9
+ "build": "echo 'nothing to build'",
10
+ "check": "tsc --noEmit && npm run smoke && npm test",
11
+ "smoke": "node tests/status.smoke.ts",
12
+ "test": "node --import jiti/register --test tests/identity.test.ts tests/prism.test.ts tests/notify.test.ts tests/provider.integration.test.ts && vitest run",
13
+ "update-models": "node scripts/update-models.js",
14
+ "test:pi": "node scripts/test-pi-compat.mjs"
15
+ },
7
16
  "keywords": [
8
17
  "pi",
9
18
  "extension",
@@ -35,7 +44,8 @@
35
44
  "@earendil-works/pi-tui": "1.1.0",
36
45
  "@types/node": "^24.10.1",
37
46
  "jiti": "2.7.0",
38
- "typescript": "6.0.3"
47
+ "typescript": "6.0.3",
48
+ "vitest": "4.1.7"
39
49
  },
40
50
  "author": "",
41
51
  "license": "MIT",
@@ -43,14 +53,5 @@
43
53
  "extensions": [
44
54
  "./index.ts"
45
55
  ]
46
- },
47
- "scripts": {
48
- "clean": "echo 'nothing to clean'",
49
- "build": "echo 'nothing to build'",
50
- "check": "tsc --noEmit && npm run smoke && npm test",
51
- "smoke": "node tests/status.smoke.ts",
52
- "test": "node --import jiti/register --test tests/identity.test.ts tests/prism.test.ts tests/notify.test.ts tests/provider.integration.test.ts",
53
- "update-models": "node scripts/update-models.js",
54
- "test:pi": "node scripts/test-pi-compat.mjs"
55
56
  }
56
- }
57
+ }