pi-hypercharm-provider 1.3.27 → 1.3.29

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
@@ -21,6 +21,9 @@ When a model needs overrides, new properties, or corrections, edit the appropria
21
21
  | `index.ts` | Provider extension code: model sync, streaming wrapper, footer-status wiring. |
22
22
  | `status.ts` | Footer-status presentation: config schema, hypercredit/rate-limit formatters, progressive-disclosure tiers, width-aware widget. Pure module — no pi imports; exercised by `tests/status.smoke.ts`. |
23
23
  | `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
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
+ | `notify.ts` | Deduplicated warning sink: fetch/parse failures go to the session UI once one is active, stderr before that, never thrown. |
26
+ | `prism.ts` | Hyper routing-header validation (sanitizer + persisted-entry re-validation). Pure module — no pi imports; exercised by `tests/prism.test.ts`. |
24
27
 
25
28
  ## Data Flow
26
29
 
@@ -53,6 +56,27 @@ Provider API ──fetch──► models.json ──apply──► patch.jso
53
56
 
54
57
  ## TL;DR
55
58
 
59
+ - **Never register into a shared pi surface with a hardcoded name** — add it to `identity.ts`; the co-installation tests enforce the namespace.
56
60
  - **Never edit `models.json`** — edit `patch.json` instead.
57
61
  - **Never edit the README model table** — run the update script instead.
58
62
  - `patch.json` and `custom-models.json` are the source files you should modify.
63
+
64
+ ## Tests
65
+
66
+ `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.
69
+
70
+ | File | Scope |
71
+ |------|-------|
72
+ | `tests/status.smoke.ts` | Footer widget layout, width math, tier building, config coercion |
73
+ | `tests/identity.test.ts` | Co-install invariant: namespaced + unique + disjoint from the official provider's reserved names |
74
+ | `tests/prism.test.ts` | Prism header/label sanitization and persisted-entry validation |
75
+ | `tests/notify.test.ts` | Warning dedupe, UI routing, stale-ctx stderr fallback |
76
+ | `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
+
78
+ The integration suite stubs `globalThis.fetch` and emits the session lifecycle
79
+ events the pi CLI emits (`session_start`, `turn_start`, `after_provider_response`,
80
+ `message_end`, `turn_end`). It must be run with jiti; `--import jiti/register`
81
+ is already wired into `npm test`.
82
+
package/README.md CHANGED
@@ -138,11 +138,13 @@ DeepSeek V4 models use the `deepseek` thinking format — the same native format
138
138
 
139
139
  ## Footer Status
140
140
 
141
- A Neuralwatt-style status line sits below the editor. It appears after the
142
- session's first HyperCharm turn completes (never before — no half-empty line
143
- on fresh sessions or other providers), refreshes its balance when the agent
144
- run fully settles, and makes no status-related API calls in sessions that
145
- never use HyperCharm:
141
+ A Neuralwatt-style status line sits below the editor. It appears as soon as a
142
+ HyperCharm model is selected — the account side (team, balance, rate limits)
143
+ renders when the session-start or model-select credits fetch lands, and the
144
+ session side joins it after the first HyperCharm turn completes. Selecting
145
+ another provider hides it (`hideOnOtherProvider` defaults to `true`), the
146
+ balance refreshes when the agent run fully settles, and sessions that never
147
+ use HyperCharm make no status-related API calls:
146
148
 
147
149
  ```
148
150
  ⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 29d
package/identity.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Single source of truth for every identifier this extension registers into
3
+ * shared Pi surfaces.
4
+ *
5
+ * Co-installation with the official `@charmland/pi-hyper-provider` is a hard
6
+ * requirement, so no identifier we register may be a bare shared name. Every
7
+ * key derives from PROVIDER_ID: provider id, custom api name, status and widget
8
+ * keys, command name, prism entry type, auth key and env var, and the on-disk
9
+ * config/cache file names. tests/identity.test.ts and
10
+ * tests/provider.integration.test.ts enforce the invariant (namespacing,
11
+ * uniqueness, and disjointness from the official provider's reserved names).
12
+ */
13
+ export const PROVIDER_ID = "hypercharm";
14
+ export const PROVIDER_DISPLAY_NAME = "HyperCharm";
15
+
16
+ /** Custom api name: keeps our streamSimple handler distinct from pi's built-in openai-completions pipeline. */
17
+ export const API_NAME = PROVIDER_ID;
18
+
19
+ export const API_KEY_ENV = "HYPERCHARM_API_KEY";
20
+ export const API_KEY_PLACEHOLDER = `$${API_KEY_ENV}`;
21
+
22
+ export const STATUS_COMMAND = `${PROVIDER_ID}-status`;
23
+ export const STATUS_KEY_SESSION = `${PROVIDER_ID}-session`;
24
+ export const STATUS_KEY_ACCOUNT = `${PROVIDER_ID}-account`;
25
+ export const WIDGET_KEY = PROVIDER_ID;
26
+ export const PRISM_ENTRY_TYPE = `${PROVIDER_ID}-prism-route`;
27
+
28
+ export const CONFIG_FILE_NAME = `${PROVIDER_ID}.json`;
29
+ export const CACHE_FILE_NAME = `${PROVIDER_ID}-models.json`;
30
+
31
+ /**
32
+ * Every distinct namespaced identifier this extension registers or writes.
33
+ * API_NAME and WIDGET_KEY alias PROVIDER_ID by design — pi keys the custom api
34
+ * handler and the below-editor widget by provider id — so the set is deduped;
35
+ * what matters is that no *value* is shared with another extension.
36
+ */
37
+ export const IDENTIFIERS: readonly string[] = Object.freeze([
38
+ ...new Set([
39
+ PROVIDER_ID,
40
+ API_KEY_ENV,
41
+ API_KEY_PLACEHOLDER,
42
+ STATUS_COMMAND,
43
+ STATUS_KEY_SESSION,
44
+ STATUS_KEY_ACCOUNT,
45
+ PRISM_ENTRY_TYPE,
46
+ CONFIG_FILE_NAME,
47
+ CACHE_FILE_NAME,
48
+ ]),
49
+ ]);
package/index.ts CHANGED
@@ -33,12 +33,15 @@
33
33
  * The right side compresses across progressive tiers as the terminal
34
34
  * narrows. The balance flips to a ⚠ warning at/below lowBalanceHc.
35
35
  *
36
- * Lifecycle (mirrors pi-neuralwatt-provider): nothing renders before this
37
- * session's first HyperCharm turn completes, so fresh sessions and other
38
- * providers' sessions see no half-empty line. Credits/team are prefetched
39
- * on session start or model select when a HyperCharm model is active, so
40
- * the first turn ends with data already cached. The balance is polled
41
- * again on pi's agent_settled event (fires only once no automatic retry,
36
+ * Lifecycle (mirrors pi-neuralwatt-provider): selecting a HyperCharm model
37
+ * shows the line — the account side renders as soon as the credits/team
38
+ * prefetch lands, and the session side (spend/requests) joins it on the
39
+ * first completed turn. hideOnOtherProvider (default true) clears
40
+ * everything the moment the active model belongs to another provider.
41
+ * Credits/team are prefetched on session start or model select when a
42
+ * 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
44
+ * only once no automatic retry,
42
45
  * compaction, or queued continuation can follow) — and nowhere else, so
43
46
  * sessions without HyperCharm turns make zero status-related API calls.
44
47
  * Between polls the balance moves optimistically: each turn's
@@ -103,7 +106,23 @@
103
106
 
104
107
  import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
105
108
  import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
109
+ import { Text } from "@earendil-works/pi-tui";
106
110
  import { USER_AGENT, loginHypercharm, refreshHypercharmToken } from "./oauth";
111
+ import {
112
+ API_KEY_PLACEHOLDER,
113
+ API_NAME,
114
+ CACHE_FILE_NAME,
115
+ CONFIG_FILE_NAME,
116
+ PRISM_ENTRY_TYPE,
117
+ PROVIDER_DISPLAY_NAME,
118
+ PROVIDER_ID,
119
+ STATUS_COMMAND,
120
+ STATUS_KEY_ACCOUNT,
121
+ STATUS_KEY_SESSION,
122
+ WIDGET_KEY,
123
+ } from "./identity";
124
+ import { createNotifier } from "./notify";
125
+ import { prismRouteFromHeaders, prismRouteLabel, readPrismRoute, type PrismRoute } from "./prism";
107
126
  import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ModelRegistry } from "@earendil-works/pi-coding-agent";
108
127
  import modelsData from "./models.json" with { type: "json" };
109
128
  import customModelsData from "./custom-models.json" with { type: "json" };
@@ -132,6 +151,18 @@ import path from "path";
132
151
 
133
152
  // ─── Types ────────────────────────────────────────────────────────────────────
134
153
 
154
+ // Warning sink for fetch/parse failures: deduplicated, routed to the session UI
155
+ // once one is active, stderr before that. Warnings must never throw.
156
+ const notifier = createNotifier();
157
+
158
+ function describeError(err: unknown): string {
159
+ return err instanceof Error ? err.message : String(err);
160
+ }
161
+
162
+ function warnAccountFetch(label: string, reason: string): void {
163
+ notifier.warn(`Unable to refresh HyperCharm ${label}: ${reason}.`);
164
+ }
165
+
135
166
  interface JsonModel {
136
167
  id: string;
137
168
  name: string;
@@ -248,11 +279,10 @@ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData):
248
279
 
249
280
  // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
250
281
 
251
- const PROVIDER_ID = "hypercharm";
252
282
  const BASE_URL = "https://hyper.charm.land/v1";
253
283
  const MODELS_URL = `${BASE_URL}/provider`;
254
284
  const CACHE_DIR = path.join(getAgentDir(), "cache");
255
- const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
285
+ const CACHE_PATH = path.join(CACHE_DIR, CACHE_FILE_NAME);
256
286
  const LIVE_FETCH_TIMEOUT_MS = 8000;
257
287
 
258
288
  const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -322,12 +352,22 @@ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<Js
322
352
  headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
323
353
  signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
324
354
  });
325
- if (!response.ok) return null;
355
+ if (!response.ok) {
356
+ notifier.warn(`HyperCharm model catalog refresh failed: HTTP ${response.status} — serving cached/embedded models.`);
357
+ return null;
358
+ }
326
359
  const data = await response.json();
327
360
  const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
328
- if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
361
+ if (!Array.isArray(apiModels) || apiModels.length === 0) {
362
+ notifier.warn("HyperCharm model catalog refresh returned no usable models — serving cached/embedded models.");
363
+ return null;
364
+ }
329
365
  return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
330
- } catch {
366
+ } catch (err) {
367
+ // An aborted signal means the session was replaced, not that Hyper failed.
368
+ if (!signal?.aborted) {
369
+ notifier.warn(`HyperCharm model catalog refresh failed: ${describeError(err)} — serving cached/embedded models.`);
370
+ }
331
371
  return null;
332
372
  }
333
373
  }
@@ -336,7 +376,10 @@ function loadCachedModels(): JsonModel[] | null {
336
376
  try {
337
377
  const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
338
378
  return Array.isArray(data) ? data : null;
339
- } catch {
379
+ } catch (err) {
380
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
381
+ notifier.warn(`Ignoring unreadable HyperCharm model cache at ${CACHE_PATH}: ${describeError(err)}.`);
382
+ }
340
383
  return null;
341
384
  }
342
385
  }
@@ -345,8 +388,9 @@ function cacheModels(models: JsonModel[]): void {
345
388
  try {
346
389
  fs.mkdirSync(CACHE_DIR, { recursive: true });
347
390
  fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
348
- } catch {
349
- // Cache write failure is non-fatal
391
+ } catch (err) {
392
+ // Non-fatal: the freshly fetched catalog still serves this session.
393
+ notifier.warn(`Could not write the HyperCharm model cache to ${CACHE_PATH}: ${describeError(err)}.`);
350
394
  }
351
395
  }
352
396
 
@@ -451,8 +495,11 @@ function loadStatusConfig(): StatusConfig {
451
495
  try {
452
496
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
453
497
  statusConfig = coerceStatusConfig(raw);
454
- } catch {
455
- // Missing or unreadable file → defaults
498
+ } catch (err) {
499
+ // A missing file is normal; anything else is worth surfacing once.
500
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
501
+ notifier.warn(`Ignoring unreadable HyperCharm status config at ${CONFIG_PATH}: ${describeError(err)} — using defaults.`);
502
+ }
456
503
  }
457
504
  return statusConfig;
458
505
  }
@@ -473,8 +520,9 @@ function writeStatusConfig(): void {
473
520
  raw.glyphs = statusConfig.glyphs;
474
521
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
475
522
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
476
- } catch {
477
- // Config write failure is non-fatal — the in-memory config still applies
523
+ } catch (err) {
524
+ // Non-fatal: the in-memory config still applies to this session.
525
+ notifier.warn(`Could not save the HyperCharm status config to ${CONFIG_PATH}: ${describeError(err)}.`);
478
526
  }
479
527
  }
480
528
 
@@ -650,7 +698,7 @@ let lastCreditsFetchAt = 0;
650
698
  let creditsInFlight: Promise<void> | null = null;
651
699
  let metaFetched = false;
652
700
 
653
- async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal): Promise<any | null> {
701
+ async function fetchJsonGet(url: string, apiKey: string, signal: AbortSignal | undefined, label: string): Promise<any | null> {
654
702
  try {
655
703
  const response = await fetch(url, {
656
704
  headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
@@ -658,9 +706,14 @@ async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal):
658
706
  ? AbortSignal.any([AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS), signal])
659
707
  : AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS),
660
708
  });
661
- if (!response.ok) return null;
709
+ if (!response.ok) {
710
+ warnAccountFetch(label, `HTTP ${response.status}`);
711
+ return null;
712
+ }
662
713
  return await response.json();
663
- } catch {
714
+ } catch (err) {
715
+ // An abort means the session was replaced or shut down, not a failure.
716
+ if (!signal?.aborted) warnAccountFetch(label, describeError(err));
664
717
  return null;
665
718
  }
666
719
  }
@@ -673,7 +726,7 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
673
726
  if (creditsInFlight) return creditsInFlight;
674
727
  creditsInFlight = (async () => {
675
728
  try {
676
- const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal);
729
+ const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal, "Hypercredit balance");
677
730
  if (data === null) return;
678
731
  // /credits can report hypercredits ("balance") or USD ("balance_usd",
679
732
  // USD-billed accounts). Handle both at the observed 20 hc = $1 rate so a
@@ -693,8 +746,8 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
693
746
  async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSignal): Promise<void> {
694
747
  if (!apiKey || metaFetched) return;
695
748
  const [teams, devices] = await Promise.all([
696
- fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal),
697
- fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal),
749
+ fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal, "team metadata"),
750
+ fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal, "device sessions"),
698
751
  ]);
699
752
  if (signal?.aborted) return;
700
753
 
@@ -719,10 +772,6 @@ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSign
719
772
 
720
773
  // ─── Status Rendering ─────────────────────────────────────────────────────────
721
774
 
722
- const WIDGET_KEY = "hypercharm";
723
- const STATUS_KEY_SESSION = "hypercharm-session";
724
- const STATUS_KEY_ACCOUNT = "hypercharm-account";
725
-
726
775
  function currentProviderId(ctx: ExtensionContext): string | undefined {
727
776
  // ctx.model is a getter that can throw on stale contexts
728
777
  try {
@@ -781,10 +830,13 @@ function renderStatus(ctx: ExtensionContext): void {
781
830
  widgetGlyphClampNotified = true;
782
831
  ctx.ui.notify("HyperCharm: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. Statusbar is unaffected.", "info");
783
832
  }
784
- // Show only after HyperCharm activity this session (like pi-neuralwatt):
785
- // no empty-gap line on fresh sessions, no stale account glare on other
786
- // providers' sessions.
787
- const accountVisible = statusConfig.account !== "off" && accountHasData(account) && hasActivity;
833
+ // Show while HyperCharm is the selected provider — the account side renders
834
+ // as soon as the session_start/model_select credits fetch lands, with no
835
+ // need to wait for a turn — and once this session recorded HyperCharm
836
+ // activity, which is what keeps the line alive after a switch when
837
+ // hideOnOtherProvider is false. The default true clears it on the switch.
838
+ const visible = hasActivity || provider === PROVIDER_ID;
839
+ const accountVisible = statusConfig.account !== "off" && accountHasData(account) && visible;
788
840
  const lowBalance =
789
841
  statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
790
842
  const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats, glyphs) : undefined;
@@ -1055,15 +1107,15 @@ let currentModels: JsonModel[] = [];
1055
1107
  function makeProviderConfig(models: JsonModel[] = currentModels) {
1056
1108
  return {
1057
1109
  baseUrl: BASE_URL,
1058
- apiKey: "$HYPERCHARM_API_KEY",
1110
+ apiKey: API_KEY_PLACEHOLDER,
1059
1111
  // Custom API name so our streamSimple registers as its own handler and
1060
1112
  // never shadows pi's built-in openai-completions pipeline for other
1061
1113
  // providers. streamHypercharm delegates to pi-ai's OpenAI-compat streamer.
1062
- api: "hypercharm",
1114
+ api: API_NAME,
1063
1115
  models,
1064
1116
  streamSimple: streamHypercharm,
1065
1117
  oauth: {
1066
- name: "HyperCharm",
1118
+ name: PROVIDER_DISPLAY_NAME,
1067
1119
  login: (callbacks) => loginHypercharm(callbacks),
1068
1120
  refreshToken: (credentials, signal) => refreshHypercharmToken(credentials, signal),
1069
1121
  getApiKey: (credentials) => String(credentials.access ?? ""),
@@ -1076,13 +1128,17 @@ export default function (pi: ExtensionAPI) {
1076
1128
  const customModels = customModelsData as JsonModel[];
1077
1129
  const patches = patchData as PatchData;
1078
1130
 
1131
+ // Prism routing state: collected per assistant request, committed at turn_end.
1132
+ let collectingPrismRoute = false;
1133
+ let prismRoute: PrismRoute | undefined;
1134
+
1079
1135
  const staleBase = loadStaleModels(embeddedModels);
1080
1136
  const staleModels = buildModels(staleBase, customModels, patches);
1081
1137
  currentModels = staleModels;
1082
1138
 
1083
1139
  pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
1084
1140
 
1085
- pi.registerCommand("hypercharm-status", {
1141
+ pi.registerCommand(STATUS_COMMAND, {
1086
1142
  description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
1087
1143
  handler: async (args, ctx) => {
1088
1144
  await handleStatusCommand(args, ctx);
@@ -1090,6 +1146,7 @@ export default function (pi: ExtensionAPI) {
1090
1146
  });
1091
1147
 
1092
1148
  pi.on("session_start", async (_event, ctx) => {
1149
+ notifier.activate(ctx);
1093
1150
  const epoch = ++statusEpoch;
1094
1151
  revalidateAbort?.abort();
1095
1152
  revalidateAbort = new AbortController();
@@ -1100,12 +1157,19 @@ export default function (pi: ExtensionAPI) {
1100
1157
 
1101
1158
  loadStatusConfig();
1102
1159
  resetStatusState();
1103
- updateStatus(ctx); // clears any carryover; activity-gated, renders nothing yet
1160
+ updateStatus(ctx); // clears any carryover; the account side lands with the credits fetch
1104
1161
  // Re-register so our identity (custom api + streamSimple) always wins
1105
1162
  // over anything that touched provider registration during load.
1106
1163
  pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1107
1164
 
1108
- resolveApiKey(ctx.modelRegistry).then(() => {
1165
+ // A failure here used to vanish: no key resolved meant no refresh and no
1166
+ // diagnostics. Surface it, then continue — without a key we serve the
1167
+ // embedded/cached catalog.
1168
+ resolveApiKey(ctx.modelRegistry)
1169
+ .catch((err) => {
1170
+ notifier.warn(`Unable to resolve HyperCharm credentials: ${describeError(err)} — serving cached/embedded models.`);
1171
+ })
1172
+ .then(() => {
1109
1173
  // A session replacement while the key resolved invalidated the
1110
1174
  // captured ctx (fast-resume, /new, /fork); nothing below may touch it.
1111
1175
  if (epoch !== statusEpoch) return;
@@ -1129,8 +1193,11 @@ export default function (pi: ExtensionAPI) {
1129
1193
  updateStatus(ctx);
1130
1194
  const model: any = (event as any).model;
1131
1195
  if (model?.provider === PROVIDER_ID && cachedApiKey) {
1196
+ // Both refreshes repaint when they land: selection alone must fill in
1197
+ // the account side (balance now, team/auth atoms a moment later)
1198
+ // instead of leaving a bare gem until the next turn.
1132
1199
  updateStatusAfter(refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false), ctx);
1133
- void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1200
+ updateStatusAfter(refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined), ctx);
1134
1201
  }
1135
1202
  });
1136
1203
 
@@ -1165,4 +1232,42 @@ export default function (pi: ExtensionAPI) {
1165
1232
  ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
1166
1233
  ctx.ui.setWidget(WIDGET_KEY, undefined);
1167
1234
  });
1235
+
1236
+ // Prism routing: Hyper's edge reports which upstream model actually served the
1237
+ // assistant request via response headers. Collection is scoped to that request
1238
+ // so auxiliary calls between turns cannot leak a route into the transcript,
1239
+ // and the route lands at turn_end as a durable session entry — never a
1240
+ // notification — so it survives reopening the session.
1241
+ pi.registerEntryRenderer(PRISM_ENTRY_TYPE, (entry, _options, theme) => {
1242
+ const route = readPrismRoute(entry.data);
1243
+ const label = route ? prismRouteLabel(route) : undefined;
1244
+ if (label === undefined) return undefined;
1245
+ return new Text(`${theme.fg("muted", "Prism")} ${theme.fg("dim", "→")} ${theme.fg("muted", label)}`, 0, 0);
1246
+ });
1247
+
1248
+ pi.on("turn_start", () => {
1249
+ collectingPrismRoute = true;
1250
+ prismRoute = undefined;
1251
+ });
1252
+
1253
+ pi.on("after_provider_response", (event) => {
1254
+ if (!collectingPrismRoute) return;
1255
+ prismRoute = prismRouteFromHeaders(event.headers);
1256
+ });
1257
+
1258
+ pi.on("message_end", (event) => {
1259
+ if (event.message.role === "assistant") collectingPrismRoute = false;
1260
+ });
1261
+
1262
+ pi.on("turn_end", (event) => {
1263
+ const route = prismRoute;
1264
+ prismRoute = undefined;
1265
+ collectingPrismRoute = false;
1266
+ if (route === undefined) return;
1267
+ if (event.message.role !== "assistant") return;
1268
+ if (event.message.provider !== PROVIDER_ID) return;
1269
+ if (event.message.stopReason === "error" || event.message.stopReason === "aborted") return;
1270
+ pi.appendEntry(PRISM_ENTRY_TYPE, route);
1271
+ });
1272
+
1168
1273
  }
package/notify.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type WarningSink = (message: string) => void;
4
+
5
+ export interface Notifier {
6
+ /** Emit a deduplicated warning through the currently available output. */
7
+ warn: WarningSink;
8
+ /** Route future warnings through this session's UI (no-op without a UI). */
9
+ activate(ctx: ExtensionContext): void;
10
+ }
11
+
12
+ /**
13
+ * Fetch and parse failures must never be silent, and must never spam: warnings
14
+ * are deduplicated by message for the life of the process. Until a UI session
15
+ * activates the notifier they go to stderr (visible in print/json modes); after
16
+ * activation they go through ctx.ui.notify. A stale ctx — a refresh landing
17
+ * after its session was replaced — falls back to stderr instead of throwing.
18
+ */
19
+ export function createNotifier(): Notifier {
20
+ const seenWarnings = new Set<string>();
21
+ const toStderr: WarningSink = (message) => {
22
+ process.stderr.write(`HyperCharm warning: ${message}\n`);
23
+ };
24
+ let emit: WarningSink = toStderr;
25
+
26
+ return {
27
+ warn(message) {
28
+ if (seenWarnings.has(message)) return;
29
+ seenWarnings.add(message);
30
+ emit(message);
31
+ },
32
+
33
+ activate(ctx) {
34
+ let hasUI = false;
35
+ try {
36
+ hasUI = ctx.hasUI;
37
+ } catch {
38
+ return;
39
+ }
40
+ if (!hasUI) return;
41
+ emit = (message) => {
42
+ try {
43
+ ctx.ui.notify(message, "warning");
44
+ } catch {
45
+ toStderr(message);
46
+ }
47
+ };
48
+ },
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.3.27",
3
+ "version": "1.3.29",
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",
13
+ "update-models": "node scripts/update-models.js"
14
+ },
7
15
  "keywords": [
8
16
  "pi",
9
17
  "extension",
@@ -21,10 +29,15 @@
21
29
  "gemma",
22
30
  "gpt-oss"
23
31
  ],
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-tui": "*"
34
+ },
24
35
  "devDependencies": {
25
36
  "@earendil-works/pi-ai": "0.85.1",
26
37
  "@earendil-works/pi-coding-agent": "0.85.1",
38
+ "@earendil-works/pi-tui": "0.85.1",
27
39
  "@types/node": "^24.10.1",
40
+ "jiti": "2.7.0",
28
41
  "typescript": "6.0.3"
29
42
  },
30
43
  "author": "",
@@ -33,12 +46,5 @@
33
46
  "extensions": [
34
47
  "./index.ts"
35
48
  ]
36
- },
37
- "scripts": {
38
- "clean": "echo 'nothing to clean'",
39
- "build": "echo 'nothing to build'",
40
- "check": "tsc --noEmit && node tests/status.smoke.ts",
41
- "smoke": "node tests/status.smoke.ts",
42
- "update-models": "node scripts/update-models.js"
43
49
  }
44
- }
50
+ }
package/prism.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { PRISM_ENTRY_TYPE } from "./identity";
2
+
3
+ export { PRISM_ENTRY_TYPE };
4
+
5
+ /** Longest routing label we accept (mirrors the official provider's limit). */
6
+ const MAX_LABEL_LENGTH = 200;
7
+ /** Control, format, line-separator, and paragraph-separator characters. */
8
+ const UNSAFE_LABEL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
9
+
10
+ export interface PrismRoute {
11
+ modelName?: string;
12
+ modelId?: string;
13
+ }
14
+
15
+ /** Trimmed, length- and character-checked routing label; undefined when unusable. */
16
+ export function prismLabel(value: unknown): string | undefined {
17
+ if (typeof value !== "string") return undefined;
18
+ const label = value.trim();
19
+ if (label === "" || label.length > MAX_LABEL_LENGTH || UNSAFE_LABEL.test(label)) return undefined;
20
+ return label;
21
+ }
22
+
23
+ /** Route captured from a provider response; undefined when neither header is usable. */
24
+ export function prismRouteFromHeaders(headers: Record<string, string> | undefined): PrismRoute | undefined {
25
+ if (!headers) return undefined;
26
+ const modelName = prismLabel(headers["x-prism-model-name"]);
27
+ const modelId = prismLabel(headers["x-prism-model-id"]);
28
+ return modelName !== undefined || modelId !== undefined ? { modelName, modelId } : undefined;
29
+ }
30
+
31
+ /** Re-validate persisted entry data: session files are user-editable input. */
32
+ export function readPrismRoute(data: unknown): PrismRoute | undefined {
33
+ if (typeof data !== "object" || data === null) return undefined;
34
+ const record = data as Record<string, unknown>;
35
+ const modelName = prismLabel(record.modelName);
36
+ const modelId = prismLabel(record.modelId);
37
+ return modelName !== undefined || modelId !== undefined ? { modelName, modelId } : undefined;
38
+ }
39
+
40
+ /** What to show for a route: the human name when present, else the model id. */
41
+ export function prismRouteLabel(route: PrismRoute): string | undefined {
42
+ return route.modelName ?? route.modelId;
43
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Stand-in for the official @charmland/pi-hyper-provider@0.4.0 registration
3
+ * surface, used by tests/provider.integration.test.ts to prove both extensions
4
+ * can be installed at once.
5
+ *
6
+ * It deliberately registers under the OFFICIAL identifiers — provider id
7
+ * "hyper", status key "hyper", /hyper-status command, hyper-prism-route entry
8
+ * renderer, HYPER_API_KEY — and publishes a model with an id we also serve
9
+ * ("glm-5.3"), so the test asserts provider-scoped model resolution instead of
10
+ * accidental global-key behaviour.
11
+ */
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ export default function officialSurfaceFixture(pi: ExtensionAPI) {
15
+ pi.registerProvider("hyper", {
16
+ baseUrl: "https://hyper.charm.land/v1",
17
+ apiKey: "$HYPER_API_KEY",
18
+ api: "openai-completions",
19
+ models: [
20
+ {
21
+ id: "glm-5.3",
22
+ name: "Official fixture GLM 5.3",
23
+ reasoning: false,
24
+ input: ["text"],
25
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
26
+ contextWindow: 4096,
27
+ maxTokens: 512,
28
+ },
29
+ ],
30
+ });
31
+
32
+ pi.registerEntryRenderer("hyper-prism-route", () => undefined);
33
+
34
+ pi.registerCommand("hyper-status", {
35
+ description: "Configure the Charm Hyper footer status",
36
+ handler: async () => {},
37
+ });
38
+
39
+ pi.on("turn_end", (_event, ctx) => {
40
+ ctx.ui.setStatus("hyper", "Hyper fixture status");
41
+ });
42
+ }