pi-hypercharm-provider 1.1.5 → 1.2.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.
package/index.ts CHANGED
@@ -17,6 +17,57 @@
17
17
  *
18
18
  * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
19
19
  *
20
+ * Footer Status Widget:
21
+ * A below-editor line shows HyperCharm session + account state:
22
+ *
23
+ * ⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 10k/10k/d · ⟳ 29d
24
+ * └─ session activity ──┘ └─────────────── account / quota ─────────────────┘
25
+ *
26
+ * The left side reports what this session has spent/sent (from Hyper's
27
+ * usage.cost extension on each chat completion — pi requests
28
+ * stream_options.include_usage, so the final SSE chunk carries cost and we
29
+ * read it off a teed response stream, no polling). The right side reports
30
+ * the team (public /v1/teams — works for API-key auth), the canonical
31
+ * Hypercredit balance (/v1/credits), per-hour/day rate limits captured from
32
+ * response headers, and OAuth device-session days remaining (/v1/devices).
33
+ * The right side compresses across progressive tiers as the terminal
34
+ * narrows. The balance flips to a ⚠ warning at/below lowBalanceHc.
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,
42
+ * compaction, or queued continuation can follow) — and nowhere else, so
43
+ * sessions without HyperCharm turns make zero status-related API calls.
44
+ *
45
+ * Unit note (observed): 20 hypercredits = $1. usage.cost.hypercredits is in
46
+ * the same display unit /v1/credits reports; usage.cost.usd ÷ 20 matches.
47
+ * usage.remaining.hypercredits is USD-denominated despite the name — we
48
+ * therefore display only the polled /v1/credits balance.
49
+ *
50
+ * Display Configuration:
51
+ * Create ~/.pi/agent/extensions/hypercharm.json:
52
+ * {
53
+ * "session": "widget", // "widget" | "statusbar" | "off"
54
+ * "account": "widget", // "widget" | "statusbar" | "off"
55
+ * "hideOnOtherProvider": true, // hide when a non-HyperCharm model is active
56
+ * "lowBalanceHc": 25 // warn threshold, null/false disables
57
+ * }
58
+ *
59
+ * - "widget" (default): rendered in the below-editor status line
60
+ * - "statusbar": rendered in the built-in pi status bar
61
+ * - "off": hidden entirely (account=off also skips/quota fetches)
62
+ *
63
+ * Manage interactively with /hypercharm-status, or non-interactively:
64
+ * /hypercharm-status session widget|statusbar|off
65
+ * /hypercharm-status account widget|statusbar|off
66
+ * /hypercharm-status hide true|false
67
+ * /hypercharm-status lowBalance <hc>|off
68
+ * /hypercharm-status refresh (re-fetch balance/team now)
69
+ * /hypercharm-status reset
70
+ *
20
71
  * Usage:
21
72
  * # Option 1: Store in auth.json (recommended)
22
73
  * # Add to ~/.pi/agent/auth.json:
@@ -33,54 +84,70 @@
33
84
  * @see https://hyper.charm.land
34
85
  */
35
86
 
36
- import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
87
+ import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
88
+ import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
89
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ModelRegistry } from "@earendil-works/pi-coding-agent";
37
90
  import modelsData from "./models.json" with { type: "json" };
38
91
  import customModelsData from "./custom-models.json" with { type: "json" };
39
92
  import patchData from "./patch.json" with { type: "json" };
40
93
  import deprecatedData from "./deprecated-models.json" with { type: "json" };
94
+ import {
95
+ buildAccountTiers,
96
+ buildSessionLine,
97
+ coerceStatusConfig,
98
+ DEFAULT_STATUS_CONFIG,
99
+ EMPTY_ACCOUNT,
100
+ EMPTY_SESSION_STATS,
101
+ StatusLineWidget,
102
+ accountHasData,
103
+ type AccountState,
104
+ type SessionStats,
105
+ type StatusConfig,
106
+ } from "./status";
41
107
  import fs from "fs";
108
+ import { hostname } from "os";
42
109
  import path from "path";
43
110
 
44
111
  // ─── Types ────────────────────────────────────────────────────────────────────
45
112
 
46
113
  interface JsonModel {
47
- id: string;
48
- name: string;
49
- reasoning: boolean;
50
- input: ("text" | "image")[];
51
- cost: {
52
- input: number;
53
- output: number;
54
- cacheRead: number;
55
- cacheWrite: number;
56
- };
57
- contextWindow: number;
58
- maxTokens: number;
59
- thinkingLevelMap?: Record<string, string | null>;
60
- compat?: {
61
- supportsDeveloperRole?: boolean;
62
- supportsStore?: boolean;
63
- maxTokensField?: "max_completion_tokens" | "max_tokens";
64
- thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
65
- supportsReasoningEffort?: boolean;
66
- requiresReasoningContentOnAssistantMessages?: boolean;
67
- };
114
+ id: string;
115
+ name: string;
116
+ reasoning: boolean;
117
+ input: ("text" | "image")[];
118
+ cost: {
119
+ input: number;
120
+ output: number;
121
+ cacheRead: number;
122
+ cacheWrite: number;
123
+ };
124
+ contextWindow: number;
125
+ maxTokens: number;
126
+ thinkingLevelMap?: Record<string, string | null>;
127
+ compat?: {
128
+ supportsDeveloperRole?: boolean;
129
+ supportsStore?: boolean;
130
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
131
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
132
+ supportsReasoningEffort?: boolean;
133
+ requiresReasoningContentOnAssistantMessages?: boolean;
134
+ };
68
135
  }
69
136
 
70
137
  interface PatchEntry {
71
- name?: string;
72
- reasoning?: boolean;
73
- input?: ("text" | "image")[];
74
- cost?: {
75
- input?: number;
76
- output?: number;
77
- cacheRead?: number;
78
- cacheWrite?: number;
79
- };
80
- contextWindow?: number;
81
- maxTokens?: number;
82
- thinkingLevelMap?: Record<string, string | null>;
83
- compat?: Record<string, unknown>;
138
+ name?: string;
139
+ reasoning?: boolean;
140
+ input?: ("text" | "image")[];
141
+ cost?: {
142
+ input?: number;
143
+ output?: number;
144
+ cacheRead?: number;
145
+ cacheWrite?: number;
146
+ };
147
+ contextWindow?: number;
148
+ maxTokens?: number;
149
+ thinkingLevelMap?: Record<string, string | null>;
150
+ compat?: Record<string, unknown>;
84
151
  }
85
152
 
86
153
  type PatchData = Record<string, PatchEntry>;
@@ -88,73 +155,73 @@ type PatchData = Record<string, PatchEntry>;
88
155
  // ─── Patch Application ────────────────────────────────────────────────────────
89
156
 
90
157
  function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
91
- const result = { ...model };
92
-
93
- if (patch.name !== undefined) result.name = patch.name;
94
- if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
95
- if (patch.input !== undefined) result.input = patch.input;
96
- if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
97
- if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
98
- if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
99
-
100
- if (patch.cost) {
101
- result.cost = {
102
- input: patch.cost.input ?? result.cost.input,
103
- output: patch.cost.output ?? result.cost.output,
104
- cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
105
- cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
106
- };
107
- }
108
- if (patch.compat) {
109
- result.compat = { ...(result.compat || {}), ...patch.compat };
110
- }
111
-
112
- if (!result.reasoning && result.compat?.thinkingFormat) {
113
- delete result.compat.thinkingFormat;
114
- }
115
- if (!result.reasoning && result.thinkingLevelMap) {
116
- delete result.thinkingLevelMap;
117
- }
118
- if (result.compat && Object.keys(result.compat).length === 0) {
119
- delete result.compat;
120
- }
121
-
122
- return result;
158
+ const result = { ...model };
159
+
160
+ if (patch.name !== undefined) result.name = patch.name;
161
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
162
+ if (patch.input !== undefined) result.input = patch.input;
163
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
164
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
165
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
166
+
167
+ if (patch.cost) {
168
+ result.cost = {
169
+ input: patch.cost.input ?? result.cost.input,
170
+ output: patch.cost.output ?? result.cost.output,
171
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
172
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
173
+ };
174
+ }
175
+ if (patch.compat) {
176
+ result.compat = { ...(result.compat || {}), ...patch.compat };
177
+ }
178
+
179
+ if (!result.reasoning && result.compat?.thinkingFormat) {
180
+ delete result.compat.thinkingFormat;
181
+ }
182
+ if (!result.reasoning && result.thinkingLevelMap) {
183
+ delete result.thinkingLevelMap;
184
+ }
185
+ if (result.compat && Object.keys(result.compat).length === 0) {
186
+ delete result.compat;
187
+ }
188
+
189
+ return result;
123
190
  }
124
191
 
125
192
  /** Full pipeline: base models → patch → custom → result */
126
193
  function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
127
- const modelMap = new Map<string, JsonModel>();
128
-
129
- // Seed with the base list plus grace-period deprecated models so patch.json
130
- // entries apply to deprecated models exactly as while the model was live
131
- // (withDeprecated keeps live data on id conflicts).
132
- for (const model of withDeprecated(base)) {
133
- modelMap.set(model.id, model);
134
- }
135
-
136
- for (const [id, patchEntry] of Object.entries(patch)) {
137
- const existing = modelMap.get(id);
138
- if (existing) {
139
- modelMap.set(id, applyPatch(existing, patchEntry));
140
- }
141
- }
142
-
143
- for (const model of custom) {
144
- const existing = modelMap.get(model.id);
145
- const patchEntry = patch[model.id];
146
- if (existing && patchEntry) {
147
- modelMap.set(model.id, applyPatch(model, patchEntry));
148
- } else if (existing) {
149
- modelMap.set(model.id, model);
150
- } else if (patchEntry) {
151
- modelMap.set(model.id, applyPatch(model, patchEntry));
152
- } else {
153
- modelMap.set(model.id, model);
154
- }
155
- }
156
-
157
- return Array.from(modelMap.values());
194
+ const modelMap = new Map<string, JsonModel>();
195
+
196
+ // Seed with the base list plus grace-period deprecated models so patch.json
197
+ // entries apply to deprecated models exactly as while the model was live
198
+ // (withDeprecated keeps live data on id conflicts).
199
+ for (const model of withDeprecated(base)) {
200
+ modelMap.set(model.id, model);
201
+ }
202
+
203
+ for (const [id, patchEntry] of Object.entries(patch)) {
204
+ const existing = modelMap.get(id);
205
+ if (existing) {
206
+ modelMap.set(id, applyPatch(existing, patchEntry));
207
+ }
208
+ }
209
+
210
+ for (const model of custom) {
211
+ const existing = modelMap.get(model.id);
212
+ const patchEntry = patch[model.id];
213
+ if (existing && patchEntry) {
214
+ modelMap.set(model.id, applyPatch(model, patchEntry));
215
+ } else if (existing) {
216
+ modelMap.set(model.id, model);
217
+ } else if (patchEntry) {
218
+ modelMap.set(model.id, applyPatch(model, patchEntry));
219
+ } else {
220
+ modelMap.set(model.id, model);
221
+ }
222
+ }
223
+
224
+ return Array.from(modelMap.values());
158
225
  }
159
226
 
160
227
  // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
@@ -169,126 +236,126 @@ const LIVE_FETCH_TIMEOUT_MS = 8000;
169
236
  const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
170
237
 
171
238
  const ON_OFF_THINKING_LEVEL_MAP: Record<string, string | null> = {
172
- off: "off",
173
- minimal: null,
174
- low: null,
175
- medium: null,
176
- high: null,
177
- xhigh: null,
178
- max: "max",
239
+ off: "off",
240
+ minimal: null,
241
+ low: null,
242
+ medium: null,
243
+ high: null,
244
+ xhigh: null,
245
+ max: "max",
179
246
  };
180
247
 
181
248
  function buildThinkingLevelMap(levels: string[]): Record<string, string | null> | undefined {
182
- if (levels.length === 0) return undefined;
183
- const available = new Set(levels);
184
- const result: Record<string, string | null> = {
185
- off: available.has("off") ? "off" : available.has("none") ? "none" : null,
186
- };
187
- for (const level of PI_THINKING_LEVELS) {
188
- result[level] = available.has(level) ? level : null;
189
- }
190
- return result;
249
+ if (levels.length === 0) return undefined;
250
+ const available = new Set(levels);
251
+ const result: Record<string, string | null> = {
252
+ off: available.has("off") ? "off" : available.has("none") ? "none" : null,
253
+ };
254
+ for (const level of PI_THINKING_LEVELS) {
255
+ result[level] = available.has(level) ? level : null;
256
+ }
257
+ return result;
191
258
  }
192
259
 
193
260
  /** Transform a model from Charm's official typed Hyper /v1/provider catalog. */
194
261
  function transformApiModel(apiModel: any): JsonModel | null {
195
- if (typeof apiModel.id !== "string" || apiModel.id.length === 0) return null;
196
-
197
- const reasoningLevels = Array.isArray(apiModel.reasoning_levels)
198
- ? apiModel.reasoning_levels.filter((level: any) => typeof level === "string")
199
- : [];
200
- const supportsReasoningEffort = reasoningLevels.length > 0;
201
- const thinkingLevelMap = supportsReasoningEffort
202
- ? buildThinkingLevelMap(reasoningLevels)
203
- : apiModel.can_reason === true
204
- ? ON_OFF_THINKING_LEVEL_MAP
205
- : undefined;
206
-
207
- return {
208
- id: apiModel.id,
209
- name: apiModel.name || apiModel.id,
210
- reasoning: apiModel.can_reason === true,
211
- thinkingLevelMap,
212
- input: apiModel.supports_attachments === true ? ["text", "image"] : ["text"],
213
- cost: {
214
- input: apiModel.cost_per_1m_in || 0,
215
- output: apiModel.cost_per_1m_out || 0,
216
- cacheRead: apiModel.cost_per_1m_in_cached || 0,
217
- cacheWrite: 0,
218
- },
219
- contextWindow: apiModel.context_window || 0,
220
- maxTokens: apiModel.default_max_tokens || apiModel.context_window || 0,
221
- compat: {
222
- supportsStore: false,
223
- supportsReasoningEffort,
224
- thinkingFormat: "deepseek",
225
- maxTokensField: "max_tokens",
226
- },
227
- };
262
+ if (typeof apiModel.id !== "string" || apiModel.id.length === 0) return null;
263
+
264
+ const reasoningLevels = Array.isArray(apiModel.reasoning_levels)
265
+ ? apiModel.reasoning_levels.filter((level: any) => typeof level === "string")
266
+ : [];
267
+ const supportsReasoningEffort = reasoningLevels.length > 0;
268
+ const thinkingLevelMap = supportsReasoningEffort
269
+ ? buildThinkingLevelMap(reasoningLevels)
270
+ : apiModel.can_reason === true
271
+ ? ON_OFF_THINKING_LEVEL_MAP
272
+ : undefined;
273
+
274
+ return {
275
+ id: apiModel.id,
276
+ name: apiModel.name || apiModel.id,
277
+ reasoning: apiModel.can_reason === true,
278
+ thinkingLevelMap,
279
+ input: apiModel.supports_attachments === true ? ["text", "image"] : ["text"],
280
+ cost: {
281
+ input: apiModel.cost_per_1m_in || 0,
282
+ output: apiModel.cost_per_1m_out || 0,
283
+ cacheRead: apiModel.cost_per_1m_in_cached || 0,
284
+ cacheWrite: 0,
285
+ },
286
+ contextWindow: apiModel.context_window || 0,
287
+ maxTokens: apiModel.default_max_tokens || apiModel.context_window || 0,
288
+ compat: {
289
+ supportsStore: false,
290
+ supportsReasoningEffort,
291
+ thinkingFormat: "deepseek",
292
+ maxTokensField: "max_tokens",
293
+ },
294
+ };
228
295
  }
229
296
 
230
297
  async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
231
- try {
232
- const response = await fetch(MODELS_URL, {
233
- headers: { Authorization: `Bearer ${apiKey}` },
234
- signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
235
- });
236
- if (!response.ok) return null;
237
- const data = await response.json();
238
- const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
239
- if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
240
- return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
241
- } catch {
242
- return null;
243
- }
298
+ try {
299
+ const response = await fetch(MODELS_URL, {
300
+ headers: { Authorization: `Bearer ${apiKey}` },
301
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
302
+ });
303
+ if (!response.ok) return null;
304
+ const data = await response.json();
305
+ const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
306
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
307
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
308
+ } catch {
309
+ return null;
310
+ }
244
311
  }
245
312
 
246
313
  function loadCachedModels(): JsonModel[] | null {
247
- try {
248
- const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
249
- return Array.isArray(data) ? data : null;
250
- } catch {
251
- return null;
252
- }
314
+ try {
315
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
316
+ return Array.isArray(data) ? data : null;
317
+ } catch {
318
+ return null;
319
+ }
253
320
  }
254
321
 
255
322
  function cacheModels(models: JsonModel[]): void {
256
- try {
257
- fs.mkdirSync(CACHE_DIR, { recursive: true });
258
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
259
- } catch {
260
- // Cache write failure is non-fatal
261
- }
323
+ try {
324
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
325
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
326
+ } catch {
327
+ // Cache write failure is non-fatal
328
+ }
262
329
  }
263
330
 
264
331
  function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
265
- const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
266
- const seen = new Set<string>();
267
- const result: JsonModel[] = [];
268
- for (const liveModel of liveModels) {
269
- const embedded = embeddedMap.get(liveModel.id);
270
- seen.add(liveModel.id);
271
- if (embedded) {
272
- // The official /v1/provider catalog is authoritative for pricing, including
273
- // legitimately zero-priced preview models. Curation (reasoning/input/compat/name)
274
- // still wins via ...embedded.
275
- result.push({
276
- ...liveModel,
277
- ...embedded,
278
- cost: liveModel.cost,
279
- contextWindow: liveModel.contextWindow || embedded.contextWindow,
280
- });
281
- } else {
282
- result.push(liveModel);
283
- }
284
- }
285
- // Append any embedded models that the live API didn't return
286
- for (const em of embeddedModels) {
287
- if (!seen.has(em.id)) {
288
- result.push(em);
289
- }
290
- }
291
- return result;
332
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
333
+ const seen = new Set<string>();
334
+ const result: JsonModel[] = [];
335
+ for (const liveModel of liveModels) {
336
+ const embedded = embeddedMap.get(liveModel.id);
337
+ seen.add(liveModel.id);
338
+ if (embedded) {
339
+ // The official /v1/provider catalog is authoritative for pricing, including
340
+ // legitimately zero-priced preview models. Curation (reasoning/input/compat/name)
341
+ // still wins via ...embedded.
342
+ result.push({
343
+ ...liveModel,
344
+ ...embedded,
345
+ cost: liveModel.cost,
346
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
347
+ });
348
+ } else {
349
+ result.push(liveModel);
350
+ }
351
+ }
352
+ // Append any embedded models that the live API didn't return
353
+ for (const em of embeddedModels) {
354
+ if (!seen.has(em.id)) {
355
+ result.push(em);
356
+ }
357
+ }
358
+ return result;
292
359
  }
293
360
 
294
361
  // Grace period for delisted models. When the provider API stops listing a
@@ -300,47 +367,47 @@ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
300
367
 
301
368
  // Grace-period deprecated models with deprecation metadata stripped.
302
369
  function activeDeprecatedModels(): JsonModel[] {
303
- const now = Date.now();
304
- const result: JsonModel[] = [];
305
- for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
306
- if (!entry?.id) continue;
307
- const removedAt = Date.parse(entry.deprecatedAt ?? "");
308
- if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
309
- const model = { ...entry } as JsonModel & { deprecatedAt?: string };
310
- delete model.deprecatedAt;
311
- result.push(model);
312
- }
313
- return result;
370
+ const now = Date.now();
371
+ const result: JsonModel[] = [];
372
+ for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
373
+ if (!entry?.id) continue;
374
+ const removedAt = Date.parse(entry.deprecatedAt ?? "");
375
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
376
+ const model = { ...entry } as JsonModel & { deprecatedAt?: string };
377
+ delete model.deprecatedAt;
378
+ result.push(model);
379
+ }
380
+ return result;
314
381
  }
315
382
 
316
383
  // Append grace-period deprecated models the list does not already have (live data wins).
317
384
  function withDeprecated(models: JsonModel[]): JsonModel[] {
318
- const seen = new Set(models.map((m) => m.id));
319
- const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
320
- return extras.length > 0 ? [...models, ...extras] : models;
385
+ const seen = new Set(models.map((m) => m.id));
386
+ const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
387
+ return extras.length > 0 ? [...models, ...extras] : models;
321
388
  }
322
389
 
323
390
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
324
- const cached = loadCachedModels();
325
- if (!cached || cached.length === 0) return embeddedModels;
391
+ const cached = loadCachedModels();
392
+ if (!cached || cached.length === 0) return embeddedModels;
326
393
 
327
- // Merge embedded models that are missing from cache (newly added models)
328
- const cachedMap = new Map(cached.map(m => [m.id, m]));
329
- for (const em of embeddedModels) {
330
- if (!cachedMap.has(em.id)) {
331
- cached.push(em);
332
- }
333
- }
334
- return cached;
394
+ // Merge embedded models that are missing from cache (newly added models)
395
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
396
+ for (const em of embeddedModels) {
397
+ if (!cachedMap.has(em.id)) {
398
+ cached.push(em);
399
+ }
400
+ }
401
+ return cached;
335
402
  }
336
403
 
337
404
  async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
338
- if (!apiKey) return null;
339
- const liveModels = await fetchLiveModels(apiKey, signal);
340
- if (!liveModels || liveModels.length === 0) return null;
341
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
342
- cacheModels(merged);
343
- return merged;
405
+ if (!apiKey) return null;
406
+ const liveModels = await fetchLiveModels(apiKey, signal);
407
+ if (!liveModels || liveModels.length === 0) return null;
408
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
409
+ cacheModels(merged);
410
+ return merged;
344
411
  }
345
412
 
346
413
  // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
@@ -349,45 +416,649 @@ let cachedApiKey: string | undefined;
349
416
  let revalidateAbort: AbortController | null = null;
350
417
 
351
418
  async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
352
- cachedApiKey = await modelRegistry.getApiKeyForProvider("hypercharm") ?? undefined;
419
+ cachedApiKey = await modelRegistry.getApiKeyForProvider(PROVIDER_ID) ?? undefined;
420
+ }
421
+
422
+ // ─── Status Display Configuration ──────────────────────────────────────────────
423
+
424
+ const CONFIG_PATH = path.join(getAgentDir(), "extensions", "hypercharm.json");
425
+
426
+ let statusConfig: StatusConfig = { ...DEFAULT_STATUS_CONFIG };
427
+
428
+ function loadStatusConfig(): StatusConfig {
429
+ try {
430
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
431
+ statusConfig = coerceStatusConfig(raw);
432
+ } catch {
433
+ // Missing or unreadable file → defaults
434
+ }
435
+ return statusConfig;
436
+ }
437
+
438
+ function writeStatusConfig(): void {
439
+ try {
440
+ let raw: Record<string, unknown> = {};
441
+ try {
442
+ const existing = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
443
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) raw = existing;
444
+ } catch {
445
+ // No existing file — start fresh
446
+ }
447
+ raw.session = statusConfig.session;
448
+ raw.account = statusConfig.account;
449
+ raw.hideOnOtherProvider = statusConfig.hideOnOtherProvider;
450
+ raw.lowBalanceHc = statusConfig.lowBalanceHc;
451
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
452
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
453
+ } catch {
454
+ // Config write failure is non-fatal — the in-memory config still applies
455
+ }
456
+ }
457
+
458
+ loadStatusConfig();
459
+
460
+ // ─── Response Metadata Capture ────────────────────────────────────────────────
461
+ // The custom streamSimple below wraps fetch per request (never globalThis —
462
+ // concurrent main/helper requests would clobber a global patch). For every
463
+ // /chat/completions response we capture x-ratelimit-* headers and tee the
464
+ // body: one copy goes to pi's OpenAI streaming layer, the other is scanned
465
+ // for the final usage chunk that Hyper extends with hypercredit cost data.
466
+
467
+ const sessionStats: SessionStats = { ...EMPTY_SESSION_STATS };
468
+ const account: AccountState = { ...EMPTY_ACCOUNT };
469
+
470
+ // Per-turn pending state — teed streams settle asynchronously, so capture
471
+ // lands in pending* and is committed at turn_end.
472
+ let pendingRequests = 0;
473
+ let pendingSpendHc = 0;
474
+ let pendingSawUsage = false;
475
+ let pendingSawOutOfCredits = false;
476
+ let outOfCreditsNotified = false;
477
+
478
+ const teeReaders = new Set<Promise<void>>();
479
+
480
+ function trackTeeReader(promise: Promise<void>): void {
481
+ teeReaders.add(promise);
482
+ const release = () => { teeReaders.delete(promise); };
483
+ promise.then(release, release);
484
+ }
485
+
486
+ function settleTeeReaders(): Promise<void> {
487
+ if (teeReaders.size === 0) return Promise.resolve();
488
+ const pending = Array.from(teeReaders);
489
+ return Promise.allSettled(pending).then(() => undefined);
490
+ }
491
+
492
+ function captureRateLimitHeaders(headers: Headers): void {
493
+ const limitHour = Number(headers.get("x-ratelimit-limit-hour"));
494
+ const limitDay = Number(headers.get("x-ratelimit-limit-day"));
495
+ const remainingHour = Number(headers.get("x-ratelimit-remaining-hour"));
496
+ const remainingDay = Number(headers.get("x-ratelimit-remaining-day"));
497
+ if (![limitHour, limitDay, remainingHour, remainingDay].every((v) => Number.isFinite(v))) return;
498
+ account.rate = { limitHour, limitDay, remainingHour, remainingDay, capturedAt: Date.now() };
499
+ }
500
+
501
+ /** Extract spend data from a parsed completion chunk/body's usage object. */
502
+ function captureUsage(obj: any): void {
503
+ const usage = obj?.usage;
504
+ if (typeof usage !== "object" || usage === null) return;
505
+ const hc = usage.cost?.hypercredits;
506
+ if (typeof hc === "number" && Number.isFinite(hc)) {
507
+ pendingSpendHc += hc;
508
+ }
509
+ pendingSawUsage = true;
510
+ }
511
+
512
+ /** Scan a teed response for the final usage chunk (SSE) or JSON body usage. */
513
+ async function readUsageFromTee(body: ReadableStream<Uint8Array>): Promise<void> {
514
+ const reader = body.getReader();
515
+ const decoder = new TextDecoder();
516
+ let buffer = "";
517
+
518
+ const processLine = (line: string): void => {
519
+ const trimmed = line.trim();
520
+ if (!trimmed.startsWith("data: ")) return;
521
+ const payload = trimmed.slice(6);
522
+ if (payload === "[DONE]") return;
523
+ try {
524
+ captureUsage(JSON.parse(payload));
525
+ } catch {
526
+ // Not JSON or no usage — benign
527
+ }
528
+ };
529
+
530
+ try {
531
+ while (true) {
532
+ const { done, value } = await reader.read();
533
+ if (done) break;
534
+ buffer += decoder.decode(value, { stream: true });
535
+ const lines = buffer.split("\n");
536
+ buffer = lines.pop() || "";
537
+ for (const line of lines) processLine(line);
538
+ }
539
+ } catch {
540
+ // Tee stream may error if the main stream is aborted — that's fine
541
+ }
542
+
543
+ const trailing = (buffer + decoder.decode(new Uint8Array(0), { stream: false })).trim();
544
+ if (trailing) {
545
+ if (trailing.startsWith("data: ")) {
546
+ processLine(trailing);
547
+ } else if (trailing.startsWith("{")) {
548
+ try {
549
+ captureUsage(JSON.parse(trailing));
550
+ } catch {
551
+ // Partial non-SSE body — ignore
552
+ }
553
+ }
554
+ }
555
+
556
+ try {
557
+ reader.releaseLock();
558
+ } catch {
559
+ // Ignore
560
+ }
561
+ }
562
+
563
+ // ─── Custom Streaming Provider ────────────────────────────────────────────────
564
+
565
+ function streamHypercharm(
566
+ model: any,
567
+ context: any,
568
+ options?: SimpleStreamOptions,
569
+ ): AssistantMessageEventStream {
570
+ const apiKey = (options as any)?.apiKey || cachedApiKey || "";
571
+ if (!apiKey) {
572
+ throw new Error(
573
+ `No API key for HyperCharm. Add it to ~/.pi/agent/auth.json, ` +
574
+ `set HYPERCHARM_API_KEY env var, or use --api-key.`,
575
+ );
576
+ }
577
+
578
+ const hyperModel = { ...model, api: "openai-completions", baseUrl: model.baseUrl || BASE_URL };
579
+
580
+ // pi hands the user's thinking selection as options.reasoning (a raw
581
+ // ThinkingLevel); streamOpenAICompletions only reads reasoningEffort.
582
+ // Replicate pi-ai's clamp+convert so levels reach the request body.
583
+ const clampedReasoning = options?.reasoning ? clampThinkingLevel(hyperModel, options.reasoning) : undefined;
584
+ const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
585
+ const { reasoning: _reasoning, ...streamOptions } = (options ?? {}) as any;
586
+
587
+ // Per-request fetch wrapper: owns its interceptor, safe under concurrency.
588
+ const upstreamFetch = (streamOptions as any).fetch ?? globalThis.fetch;
589
+ const metaFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
590
+ const response = await upstreamFetch(input as any, init);
591
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
592
+ if (!url.includes("/chat/completions")) return response;
593
+
594
+ pendingRequests += 1;
595
+ captureRateLimitHeaders(response.headers);
596
+ if (response.status === 402) pendingSawOutOfCredits = true;
597
+ if (!response.ok || !response.body) return response;
598
+
599
+ const [bodyForSdk, bodyForMeta] = response.body.tee();
600
+ trackTeeReader(readUsageFromTee(bodyForMeta));
601
+ return new Response(bodyForSdk, {
602
+ headers: response.headers,
603
+ status: response.status,
604
+ statusText: response.statusText,
605
+ });
606
+ };
607
+
608
+ return streamOpenAICompletions(hyperModel, context, {
609
+ ...streamOptions,
610
+ fetch: metaFetch,
611
+ reasoningEffort,
612
+ apiKey,
613
+ } as any);
614
+ }
615
+
616
+ // ─── Account Metadata Fetching ────────────────────────────────────────────────
617
+
618
+ const CREDITS_MIN_INTERVAL_MS = 15_000;
619
+ const ACCOUNT_FETCH_TIMEOUT_MS = 8_000;
620
+
621
+ let statusAbort: AbortController | null = null;
622
+ let lastCreditsFetchAt = 0;
623
+ let creditsInFlight: Promise<void> | null = null;
624
+ let metaFetched = false;
625
+
626
+ async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal): Promise<any | null> {
627
+ try {
628
+ const response = await fetch(url, {
629
+ headers: { Authorization: `Bearer ${apiKey}` },
630
+ signal: signal
631
+ ? AbortSignal.any([AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS), signal])
632
+ : AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS),
633
+ });
634
+ if (!response.ok) return null;
635
+ return await response.json();
636
+ } catch {
637
+ return null;
638
+ }
639
+ }
640
+
641
+ /** Canonical Hypercredit balance from /v1/credits. Throttled unless forced. */
642
+ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefined, force: boolean): Promise<void> {
643
+ if (!apiKey) return Promise.resolve();
644
+ if (!force && Date.now() - lastCreditsFetchAt < CREDITS_MIN_INTERVAL_MS) return Promise.resolve();
645
+ lastCreditsFetchAt = Date.now();
646
+ if (creditsInFlight) return creditsInFlight;
647
+ creditsInFlight = (async () => {
648
+ try {
649
+ const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal);
650
+ if (data === null) return;
651
+ const balance = data?.balance;
652
+ if (typeof balance === "number" && Number.isFinite(balance)) {
653
+ account.balance = balance;
654
+ }
655
+ } finally {
656
+ creditsInFlight = null;
657
+ }
658
+ })();
659
+ return creditsInFlight;
660
+ }
661
+
662
+ /** Team name (/v1/teams) + OAuth device-session expiry (/v1/devices). */
663
+ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSignal): Promise<void> {
664
+ if (!apiKey || metaFetched) return;
665
+ const [teams, devices] = await Promise.all([
666
+ fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal),
667
+ fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal),
668
+ ]);
669
+ if (signal?.aborted) return;
670
+
671
+ const teamName = teams?.items?.[0]?.name;
672
+ if (typeof teamName === "string" && teamName.trim()) {
673
+ account.teamName = teamName.trim();
674
+ }
675
+
676
+ // Devices: the OAuth device flow registers this machine as
677
+ // `Pi (<hostname>)`. Match by name; skip silently for API-key auth
678
+ // (the endpoint returns OAuth sessions and may be empty).
679
+ if (Array.isArray(devices?.items)) {
680
+ const own = devices.items.find((d: any) => typeof d?.name === "string" && d.name === `Pi (${hostname()})`);
681
+ const expMs = own ? Date.parse(own.expires_at ?? "") : NaN;
682
+ if (!Number.isNaN(expMs)) {
683
+ account.authDaysLeft = Math.max(0, Math.ceil((expMs - Date.now()) / 86_400_000));
684
+ }
685
+ }
686
+
687
+ if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
688
+ }
689
+
690
+ // ─── Status Rendering ─────────────────────────────────────────────────────────
691
+
692
+ const WIDGET_KEY = "hypercharm";
693
+ const STATUS_KEY_SESSION = "hypercharm-session";
694
+ const STATUS_KEY_ACCOUNT = "hypercharm-account";
695
+
696
+ function currentProviderId(ctx: ExtensionContext): string | undefined {
697
+ // ctx.model is a getter that can throw on stale contexts
698
+ try {
699
+ return (ctx.model as any)?.provider as string | undefined;
700
+ } catch {
701
+ return undefined;
702
+ }
703
+ }
704
+
705
+ function updateStatus(ctx: ExtensionContext): void {
706
+ const provider = currentProviderId(ctx);
707
+ const hiddenByOtherProvider =
708
+ statusConfig.hideOnOtherProvider && provider !== undefined && provider !== PROVIDER_ID;
709
+
710
+ const clearAll = () => {
711
+ ctx.ui.setStatus(STATUS_KEY_SESSION, undefined);
712
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
713
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
714
+ };
715
+
716
+ if (hiddenByOtherProvider) {
717
+ clearAll();
718
+ return;
719
+ }
720
+
721
+ const hasActivity = sessionStats.requests > 0 || sessionStats.spendHc > 0;
722
+ const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats) : undefined;
723
+ // Show only after HyperCharm activity this session (like pi-neuralwatt):
724
+ // no empty-gap line on fresh sessions, no stale account glare on other
725
+ // providers' sessions.
726
+ const accountVisible = statusConfig.account !== "off" && accountHasData(account) && hasActivity;
727
+ const lowBalance =
728
+ statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
729
+ const accTiers = accountVisible ? buildAccountTiers(account, lowBalance) : [];
730
+
731
+ // Status bar (built-in footer slots)
732
+ const sBar = statusConfig.session === "statusbar" ? sessionLine : undefined;
733
+ const aBar = statusConfig.account === "statusbar" && accountVisible ? accTiers[0] : undefined;
734
+ if (sBar && aBar) {
735
+ // Combined to avoid eating two footer slots
736
+ ctx.ui.setStatus(STATUS_KEY_SESSION, ctx.ui.theme.fg(lowBalance ? "warning" : "dim", `${sBar} · ${aBar}`));
737
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
738
+ } else {
739
+ ctx.ui.setStatus(STATUS_KEY_SESSION, sBar ? ctx.ui.theme.fg("dim", sBar) : undefined);
740
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, aBar ? ctx.ui.theme.fg(lowBalance ? "warning" : "dim", aBar) : undefined);
741
+ }
742
+
743
+ // Below-editor widget (two-zone, width-aware)
744
+ const leftW = statusConfig.session === "widget" ? sessionLine : undefined;
745
+ const rightW = statusConfig.account === "widget" && accountVisible ? accTiers : undefined;
746
+ if (leftW !== undefined || (rightW !== undefined && rightW.length > 0)) {
747
+ ctx.ui.setWidget(
748
+ WIDGET_KEY,
749
+ (_tui: any, theme: any) => new StatusLineWidget(theme, leftW ?? "", rightW ?? [], lowBalance),
750
+ { placement: "belowEditor" },
751
+ );
752
+ } else {
753
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
754
+ }
755
+ }
756
+
757
+ function resetStatusState(): void {
758
+ sessionStats.requests = 0;
759
+ sessionStats.spendHc = 0;
760
+ Object.assign(account, EMPTY_ACCOUNT);
761
+ pendingRequests = 0;
762
+ pendingSpendHc = 0;
763
+ pendingSawUsage = false;
764
+ pendingSawOutOfCredits = false;
765
+ outOfCreditsNotified = false;
766
+ lastCreditsFetchAt = 0;
767
+ metaFetched = false;
768
+ }
769
+
770
+ /** Commit per-turn pending capture into session state (after tees settle). */
771
+ function commitPending(ctx: ExtensionContext): void {
772
+ if (!pendingSawUsage && pendingRequests === 0) return;
773
+ sessionStats.requests += pendingRequests;
774
+ sessionStats.spendHc += pendingSpendHc;
775
+ pendingRequests = 0;
776
+ pendingSpendHc = 0;
777
+ pendingSawUsage = false;
778
+
779
+ if (pendingSawOutOfCredits) {
780
+ pendingSawOutOfCredits = false;
781
+ // Re-fetch now so the balance reflects exhaustion immediately
782
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
783
+ if (!outOfCreditsNotified && ctx.hasUI) {
784
+ outOfCreditsNotified = true;
785
+ ctx.ui.notify("HyperCharm is out of Hypercredits — recharge at hyper.charm.land", "error");
786
+ }
787
+ }
788
+ }
789
+
790
+ // ─── Status Command ────────────────────────────────────────────────────────────
791
+
792
+ function statusSummary(): string {
793
+ const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc}`;
794
+ return `session=${statusConfig.session}, account=${statusConfig.account}, hideOnOtherProvider=${statusConfig.hideOnOtherProvider}, lowBalanceHc=${lb}`;
795
+ }
796
+
797
+ const STATUS_USAGE =
798
+ "Usage: /hypercharm-status [session|account widget|statusbar|off · hide true|false · lowBalance <hc>|off · refresh · reset]";
799
+
800
+ async function handleStatusCommand(args: string, ctx: ExtensionContext): Promise<void> {
801
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
802
+
803
+ if (tokens.length === 0) {
804
+ if (!ctx.hasUI) {
805
+ ctx.ui.notify(statusSummary(), "info");
806
+ return;
807
+ }
808
+ await configureStatusInteractive(ctx);
809
+ return;
810
+ }
811
+
812
+ const [rawKey, rawValue] = tokens;
813
+ const key = rawKey.toLowerCase();
814
+ const value = rawValue?.toLowerCase();
815
+
816
+ if (key === "refresh") {
817
+ metaFetched = false;
818
+ await Promise.all([
819
+ refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true),
820
+ refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined),
821
+ ]);
822
+ updateStatus(ctx);
823
+ const bal = account.balance !== null ? `${account.balance} hc` : "unknown";
824
+ ctx.ui.notify(`HyperCharm balance: ${bal}. ${statusSummary()}`, "info");
825
+ return;
826
+ }
827
+
828
+ if (key === "reset" && tokens.length === 1) {
829
+ statusConfig = { ...DEFAULT_STATUS_CONFIG };
830
+ writeStatusConfig();
831
+ updateStatus(ctx);
832
+ ctx.ui.notify(`HyperCharm status reset. ${statusSummary()}`, "info");
833
+ return;
834
+ }
835
+
836
+ if ((key === "session" || key === "account") && tokens.length === 2) {
837
+ if (value !== "widget" && value !== "statusbar" && value !== "off") {
838
+ ctx.ui.notify(STATUS_USAGE, "error");
839
+ return;
840
+ }
841
+ statusConfig[key] = value;
842
+ writeStatusConfig();
843
+ if (value !== "off" && key === "account") {
844
+ // Turning account on: make sure we have data to show
845
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
846
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
847
+ }
848
+ updateStatus(ctx);
849
+ ctx.ui.notify(`HyperCharm ${key} line: ${value}. ${statusSummary()}`, "info");
850
+ return;
851
+ }
852
+
853
+ if ((key === "hide" || key === "hideonotherprovider") && tokens.length === 2) {
854
+ if (value !== "true" && value !== "false") {
855
+ ctx.ui.notify(STATUS_USAGE, "error");
856
+ return;
857
+ }
858
+ statusConfig.hideOnOtherProvider = value === "true";
859
+ writeStatusConfig();
860
+ updateStatus(ctx);
861
+ ctx.ui.notify(`HyperCharm status. ${statusSummary()}`, "info");
862
+ return;
863
+ }
864
+
865
+ if (key === "lowbalance" && tokens.length === 2) {
866
+ if (value === "off") {
867
+ statusConfig.lowBalanceHc = null;
868
+ } else {
869
+ const n = Number(value);
870
+ if (!Number.isFinite(n) || n <= 0) {
871
+ ctx.ui.notify(STATUS_USAGE, "error");
872
+ return;
873
+ }
874
+ statusConfig.lowBalanceHc = n;
875
+ }
876
+ writeStatusConfig();
877
+ updateStatus(ctx);
878
+ ctx.ui.notify(`HyperCharm status. ${statusSummary()}`, "info");
879
+ return;
880
+ }
881
+
882
+ ctx.ui.notify(STATUS_USAGE, "error");
883
+ }
884
+
885
+ async function configureStatusInteractive(ctx: ExtensionContext): Promise<void> {
886
+ const modes = ["widget", "statusbar", "off"] as const;
887
+ const nextMode = (m: string) => modes[(modes.indexOf(m as any) + 1) % modes.length];
888
+
889
+ for (;;) {
890
+ const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc} hc`;
891
+ const sessionOpt = `Session line (spend/requests): ${statusConfig.session}`;
892
+ const accountOpt = `Account line (team/balance/rate limits): ${statusConfig.account}`;
893
+ const hideOpt = `Hide on other providers: ${statusConfig.hideOnOtherProvider ? "on" : "off"}`;
894
+ const lbOpt = `Low-balance warning: ${lb}`;
895
+ const refreshOpt = "Refresh balance now";
896
+ const doneOpt = "Done";
897
+
898
+ const choice = await ctx.ui.select("HyperCharm footer status", [
899
+ sessionOpt,
900
+ accountOpt,
901
+ hideOpt,
902
+ lbOpt,
903
+ refreshOpt,
904
+ doneOpt,
905
+ ]);
906
+
907
+ if (choice === undefined || choice === doneOpt) {
908
+ updateStatus(ctx);
909
+ return;
910
+ }
911
+ if (choice === sessionOpt) {
912
+ statusConfig.session = nextMode(statusConfig.session);
913
+ writeStatusConfig();
914
+ continue;
915
+ }
916
+ if (choice === accountOpt) {
917
+ statusConfig.account = nextMode(statusConfig.account);
918
+ writeStatusConfig();
919
+ if (statusConfig.account !== "off") {
920
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
921
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
922
+ }
923
+ continue;
924
+ }
925
+ if (choice === hideOpt) {
926
+ statusConfig.hideOnOtherProvider = !statusConfig.hideOnOtherProvider;
927
+ writeStatusConfig();
928
+ updateStatus(ctx);
929
+ continue;
930
+ }
931
+ if (choice === lbOpt) {
932
+ const presets = ["off", "10", "25", "50", "100", "200", "500"];
933
+ const current = statusConfig.lowBalanceHc === null ? "off" : String(statusConfig.lowBalanceHc);
934
+ const ordered = presets.includes(current) ? presets : [current, ...presets];
935
+ const pick = await ctx.ui.select("Warn at/below balance (hc)", ordered);
936
+ if (pick !== undefined) {
937
+ statusConfig.lowBalanceHc = pick === "off" ? null : Number(pick);
938
+ writeStatusConfig();
939
+ updateStatus(ctx);
940
+ }
941
+ continue;
942
+ }
943
+ if (choice === refreshOpt) {
944
+ metaFetched = false;
945
+ await Promise.all([
946
+ refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true),
947
+ refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined),
948
+ ]);
949
+ updateStatus(ctx);
950
+ continue;
951
+ }
952
+ }
353
953
  }
354
954
 
355
955
  // ─── Extension Entry Point ────────────────────────────────────────────────────
356
956
 
957
+ // The currently-registered model list — starts stale, hot-swapped when the
958
+ // live catalog lands. Provider identity funnels through makeProviderConfig so
959
+ // the stream handler and models never desync.
960
+ let currentModels: JsonModel[] = [];
961
+
962
+ function makeProviderConfig(models: JsonModel[] = currentModels) {
963
+ return {
964
+ baseUrl: BASE_URL,
965
+ apiKey: "$HYPERCHARM_API_KEY",
966
+ // Custom API name so our streamSimple registers as its own handler and
967
+ // never shadows pi's built-in openai-completions pipeline for other
968
+ // providers. streamHypercharm delegates to pi-ai's OpenAI-compat streamer.
969
+ api: "hypercharm",
970
+ models,
971
+ streamSimple: streamHypercharm,
972
+ };
973
+ }
974
+
357
975
  export default function (pi: ExtensionAPI) {
358
- const embeddedModels = modelsData as JsonModel[];
359
- const customModels = customModelsData as JsonModel[];
360
- const patches = patchData as PatchData;
361
-
362
- const staleBase = loadStaleModels(embeddedModels);
363
- const staleModels = buildModels(staleBase, customModels, patches);
364
-
365
- pi.registerProvider("hypercharm", {
366
- baseUrl: BASE_URL,
367
- apiKey: "$HYPERCHARM_API_KEY",
368
- api: "openai-completions",
369
- models: staleModels,
370
- });
371
-
372
- pi.on("session_start", async (_event, ctx) => {
373
- revalidateAbort?.abort();
374
- revalidateAbort = new AbortController();
375
- const signal = revalidateAbort.signal;
376
- resolveApiKey(ctx.modelRegistry).then(() => {
377
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
378
- if (freshBase && !signal.aborted) {
379
- pi.registerProvider("hypercharm", {
380
- baseUrl: BASE_URL,
381
- apiKey: "$HYPERCHARM_API_KEY",
382
- api: "openai-completions",
383
- models: buildModels(freshBase, customModels, patches),
384
- });
385
- }
386
- });
387
- });
388
- });
389
-
390
- pi.on("session_shutdown", () => {
391
- revalidateAbort?.abort();
392
- });
976
+ const embeddedModels = modelsData as JsonModel[];
977
+ const customModels = customModelsData as JsonModel[];
978
+ const patches = patchData as PatchData;
979
+
980
+ const staleBase = loadStaleModels(embeddedModels);
981
+ const staleModels = buildModels(staleBase, customModels, patches);
982
+ currentModels = staleModels;
983
+
984
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
985
+
986
+ pi.registerCommand("hypercharm-status", {
987
+ description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
988
+ handler: async (args, ctx) => {
989
+ await handleStatusCommand(args, ctx);
990
+ },
991
+ });
992
+
993
+ pi.on("session_start", async (_event, ctx) => {
994
+ revalidateAbort?.abort();
995
+ revalidateAbort = new AbortController();
996
+ const signal = revalidateAbort.signal;
997
+ statusAbort?.abort();
998
+ statusAbort = new AbortController();
999
+
1000
+ loadStatusConfig();
1001
+ resetStatusState();
1002
+ updateStatus(ctx); // clears any carryover; activity-gated, renders nothing yet
1003
+ // Re-register so our identity (custom api + streamSimple) always wins
1004
+ // over anything that touched provider registration during load.
1005
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1006
+
1007
+ resolveApiKey(ctx.modelRegistry).then(() => {
1008
+ // Prefetch credits/team metadata only when a HyperCharm model is active
1009
+ // (pi-neuralwatt also prefetches so the first turn ends with data, but
1010
+ // gating here avoids API calls in sessions that never use the provider).
1011
+ if (currentProviderId(ctx) === PROVIDER_ID) {
1012
+ void refreshCredits(cachedApiKey, statusAbort!.signal, true).then(() => updateStatus(ctx));
1013
+ void refreshAccountMeta(cachedApiKey, statusAbort!.signal).then(() => updateStatus(ctx));
1014
+ }
1015
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1016
+ if (freshBase && !signal.aborted) {
1017
+ currentModels = buildModels(freshBase, customModels, patches);
1018
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1019
+ }
1020
+ });
1021
+ });
1022
+ });
1023
+
1024
+ pi.on("model_select", (event, ctx) => {
1025
+ updateStatus(ctx);
1026
+ const model: any = (event as any).model;
1027
+ if (model?.provider === PROVIDER_ID && cachedApiKey) {
1028
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false).then(() => updateStatus(ctx));
1029
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1030
+ }
1031
+ });
1032
+
1033
+ pi.on("turn_end", async (_event, ctx) => {
1034
+ // Ensure every concurrent response tee has landed before committing.
1035
+ await settleTeeReaders();
1036
+ commitPending(ctx);
1037
+ // If the session_start/model_select credits fetch raced or failed, retry
1038
+ // once we have real activity so the very first turn shows the balance.
1039
+ if (sessionStats.requests > 0 && account.balance === null) {
1040
+ await refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false);
1041
+ }
1042
+ updateStatus(ctx);
1043
+ });
1044
+
1045
+ // agent_settled (not agent_end): fires only when no automatic retry,
1046
+ // compaction, or queued continuation can follow — the one moment polling
1047
+ // /v1/credits is both fresh and not redundant. Gated on session activity
1048
+ // so sessions without HyperCharm turns make zero API calls here.
1049
+ pi.on("agent_settled", async (_event, ctx) => {
1050
+ if (sessionStats.requests > 0 || sessionStats.spendHc > 0) {
1051
+ await refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false);
1052
+ if (!metaFetched) await refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1053
+ updateStatus(ctx);
1054
+ }
1055
+ });
1056
+
1057
+ pi.on("session_shutdown", (_event, ctx) => {
1058
+ revalidateAbort?.abort();
1059
+ statusAbort?.abort();
1060
+ ctx.ui.setStatus(STATUS_KEY_SESSION, undefined);
1061
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
1062
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
1063
+ });
393
1064
  }