@darodrig/pi-jetbrains-junie-bridge 0.0.0-stage → 1.4.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/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @darodrig/pi-jetbrains-junie-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Pi](https://pi.dev/) extension that lets you use [JetBrains Junie](https://junie.jetbrains.com/) as the AI backend for the Pi coding agent — using your existing Junie subscription.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@darodrig/pi-jetbrains-junie-bridge
9
+ ```
10
+
11
+ Then inside `pi` run the `/login` command. Select `Use a subscription` and then `JetBrains Junie` to authenticate.
12
+
13
+ Once authenticated, run `/model` to select a model provided by the junie bridge.
14
+
15
+ ## Features
16
+
17
+ - **OAuth login** — browser-based JetBrains authentication with automatic token refresh
18
+ - **Balance tracking** — session cost and remaining balance shown in Pi's status line (see footer) while a Junie model is selected
19
+ ![Screnshot showcasing balance tracking](./docs/balance_tracking.png)
20
+ - **`/junie` command** — check proxy status, balance, and available models from within Pi
21
+ ![Screnshot showcasing the `junie` command](./docs/junie_command.png)
22
+ - **Automatic context compaction** — when a conversation outgrows the model's context
23
+ window, Pi compacts it and retries instead of failing. This works out of the box for
24
+ all four model families: the bridge passes the backend's original overflow error
25
+ through unchanged, so Pi's built-in detection recognises it (verified live against
26
+ Claude, OpenAI, Grok and Gemini).
27
+
28
+ ## Available Models
29
+
30
+ Anthropic (Claude), OpenAI, xAI (Grok) and Google (Gemini) models are supported. The reasoning effort is adjustable in Pi.
31
+
32
+ The list below is mostly in sync with the models Junie itself offers, as published at <https://llm24.net/llm/junie.txt>.
33
+ It is extracted from the Junie CLI release **26.10.12 (build 3659.7)** — see `JUNIE_INSIGHTS.md` for the procedure.
34
+
35
+ **Anthropic:**
36
+ - `claude-sonnet-4-6`
37
+ - `claude-sonnet-5`
38
+ - `claude-opus-4-6`
39
+ - `claude-opus-4-7`
40
+ - `claude-opus-4-8`
41
+ - `claude-opus-5`
42
+ - `claude-opus-5-5`
43
+ - `claude-sonnet-5-5`
44
+ - `claude-haiku-5-5`
45
+ - `claude-fable-5`
46
+ - `claude-fable-5-1`
47
+
48
+ **OpenAI:**
49
+ - `openai-gpt-5-2`
50
+ - `openai-gpt-5-4`
51
+ - `openai-gpt-5-5`
52
+ - `openai-gpt-5-6-luna`
53
+ - `openai-gpt-5-6-terra`
54
+ - `openai-gpt-5-6-sol`
55
+ - `openai-gpt-6-astra`
56
+ - `openai-gpt-6-luna`
57
+ - `openai-gpt-6-sol`
58
+ - `openai-gpt-6-1-sol`
59
+
60
+ **xAI:**
61
+ - `grok-4-3`
62
+ - `grok-4-5`
63
+ - `grok-4-6`
64
+ - `grok-4-7`
65
+
66
+ **Google:**
67
+ - `gemini-3-flash-preview`
68
+ - `gemini-3.1-pro-preview`
69
+ - `gemini-3.1-flash-lite`
70
+ - `gemini-3.5-flash`
71
+ - `gemini-3.5-flash-lite`
72
+ - `gemini-3.6-flash`
73
+ - `gemini-3.7-flash`
74
+ - `gemini-3.8-flash`
75
+ - `gemini-4-argon`
76
+
77
+ Missing models:
78
+ - **`deepseek-v4.1-flash`** — not implemented yet. JetBrains routes DeepSeek through a different backend (AliCloud), and every path/header combination tried so far is rejected by the Grazie gateway for subscription OAuth tokens.
79
+ - **`kimi-k2.5`, `muse-spark-1.x`** — listed on llm24.net but gated to internal/EAP licences; every route tried answers 400/404.
80
+ - **`gemini-3-pro-preview`** — in the Junie JAR, but the Google publisher 404s it (not live).
81
+ - **Older OpenAI generations** (`gpt-5-2025-08-07`, `gpt-5.3-codex`) — reachable, but deliberately left out in favour of the current GPT-5 models.
82
+ - **`gpt`, `grok`, `gemini-pro`, `gemini-flash`** — stale aliases in that list; the backend answers `Model not found` for them.
83
+
84
+ ## Proxy Support
85
+
86
+ The extension respects the standard `HTTPS_PROXY` / `HTTP_PROXY` environment variables. This is useful in corporate environments where internet access is only available through a proxy.
87
+
88
+ All outgoing requests to JetBrains (OAuth and Grazie backend) are routed through the configured proxy. Local traffic between Pi and the bridge stays direct.
89
+
90
+ ```bash
91
+ # Example
92
+ export HTTPS_PROXY=http://proxy.corp.example.com:8080
93
+ ```
94
+
95
+ You can verify the active proxy with the `/junie` command inside Pi.
96
+
97
+ ## Requirements
98
+
99
+ - Node.js 20+
100
+ - A [JetBrains Junie subscription](https://junie.jetbrains.com/)
101
+
102
+
103
+ ## How It Works
104
+
105
+ The extension starts a local proxy server that translates between Pi and JetBrains' Grazie backend. On first use, you'll authenticate via JetBrains OAuth (browser-based PKCE flow).
106
+
107
+ ```
108
+ ┌─────────┐ ┌──────────────┐ ┌──────────────┐
109
+ │ Pi │────>│ pi-junie │────>│ Junie/Grazie │
110
+ │ agent │ │ local proxy │ │ backend │
111
+ └─────────┘ └──────────────┘ └──────────────┘
112
+ ```
113
+
114
+ - OpenAI models (`openai-gpt-*`) are forwarded via `/v1/responses` (OpenAI Responses API) so reasoning effort works together with tool calls
115
+ - Anthropic models (`claude-*`) are forwarded via `/v1/messages`
116
+ - Grok models (`grok-*`) use the same Responses API, with the routing header that Junie uses for xAI
117
+ - Gemini models (`gemini-*`) are forwarded to the Vertex-style `generateContent` endpoint
118
+ - The proxy runs on an ephemeral port and shuts down when Pi exits
119
+
120
+ ## Disclaimer
121
+
122
+ This extension includes a proxy server reverse-engineered from the official [JetBrains Junie CLI](https://github.com/JetBrains/junie).
123
+ It is not officially supported by JetBrains. Use it in accordance with the [JetBrains AI Service Terms of Service](https://www.jetbrains.com/legal/docs/toolbox/ai-service-terms/).
124
+
125
+ ## License
126
+
127
+ MIT
Binary file
Binary file
package/index.ts ADDED
@@ -0,0 +1,525 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ Theme,
6
+ ThemeColor,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { createRequire } from "node:module";
9
+ import { startServer } from "./lib/server.mjs";
10
+ import { junieLogin, junieRefreshToken } from "./lib/oauth.mjs";
11
+ import { buildProviderModels, cleanOldModelsJson } from "./lib/models.mjs";
12
+ import { getProxyDiagnostics } from "./lib/proxy.mjs";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { version: PLUGIN_VERSION } = require("./package.json");
16
+
17
+ /** A run of text with one style — styling is applied after wrapping, so that
18
+ * the ANSI escapes never confuse the width arithmetic. */
19
+ type Span = { text: string; color?: ThemeColor; bold?: boolean; italic?: boolean };
20
+
21
+ function styleSpan({ text, color, bold, italic }: Span, theme: Theme): string {
22
+ let styled = text;
23
+ if (bold) styled = theme.bold(styled);
24
+ if (italic) styled = theme.italic(styled);
25
+ if (color) styled = theme.fg(color, styled);
26
+ return styled;
27
+ }
28
+
29
+ /** Word-wrap styled spans, keeping each word's style intact. */
30
+ function wrapSpans(spans: Span[], width: number, indent: string, theme: Theme): string[] {
31
+ const words = spans.flatMap((span) =>
32
+ span.text.split(/\s+/).filter(Boolean).map((text) => ({ ...span, text })),
33
+ );
34
+
35
+ const lines: string[] = [];
36
+ let current: Span[] = [];
37
+ let length = 0;
38
+ const flush = (continuation: string) => {
39
+ if (current.length > 0) lines.push(continuation + current.map((s) => styleSpan(s, theme)).join(" "));
40
+ current = [];
41
+ length = 0;
42
+ };
43
+
44
+ for (const word of words) {
45
+ const added = length === 0 ? word.text.length : length + 1 + word.text.length;
46
+ if (length > 0 && added > width) flush(lines.length === 0 ? indent : `${indent} `);
47
+ current.push(word);
48
+ length = length === 0 ? word.text.length : added;
49
+ }
50
+ flush(lines.length === 0 ? indent : `${indent} `);
51
+
52
+ return lines.length > 0 ? lines : [""];
53
+ }
54
+
55
+ /**
56
+ * Colour a value word by word — never inside a word, so that "−$2.14" survives
57
+ * wrapping in one piece. Money is highlighted, and `backticks` mark a value
58
+ * worth picking out (the license type), which keeps the decision in the report
59
+ * text instead of teaching the renderer about content.
60
+ */
61
+ function valueSpans(text: string, color: ThemeColor): Span[] {
62
+ return text
63
+ .split(/\s+/)
64
+ .filter(Boolean)
65
+ .map((word) => {
66
+ if (word.startsWith("`") && word.endsWith("`")) {
67
+ return { text: word.slice(1, -1), color: "warning" as ThemeColor, bold: true };
68
+ }
69
+ return { text: word.replaceAll("`", ""), color: /\$\d/.test(word) ? ("success" as ThemeColor) : color };
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Escape, in every encoding a terminal may use for it.
75
+ *
76
+ * A bare "\x1b" only arrives in legacy mode. With the Kitty keyboard protocol
77
+ * (which iTerm2 negotiates) Escape is `CSI 27 u` — optionally carrying
78
+ * modifier and event-type fields — and xterm's modifyOtherKeys mode sends
79
+ * `CSI 27 ; <mod> ; 27 ~` instead. Matching only "\x1b" misses both.
80
+ * pi-tui's `matchesKey` handles all of this, but importing it is not an
81
+ * option (see showOverlay below).
82
+ */
83
+ function isEscapeKey(data: string): boolean {
84
+ if (data === "\x1b") return true;
85
+
86
+ // CSI u: \x1b[<codepoint>[:alternates][;<mod>[:<event>]]u — 27 = Escape
87
+ const csiU = data.match(/^\x1b\[(\d+)(?::\d*)*(?:;\d+(?::\d+)?)?u$/);
88
+ if (csiU) return csiU[1] === "27";
89
+
90
+ // modifyOtherKeys: \x1b[27;<mod>;<codepoint>~
91
+ return /^\x1b\[27;\d+;27~$/.test(data);
92
+ }
93
+
94
+ /** Enter, in legacy (\r or \n) and Kitty (`CSI 13 u`) encodings. */
95
+ function isEnterKey(data: string): boolean {
96
+ if (data === "\r" || data === "\n") return true;
97
+ const csiU = data.match(/^\x1b\[(\d+)(?::\d*)*(?:;\d+(?::\d+)?)?u$/);
98
+ return csiU?.[1] === "13";
99
+ }
100
+
101
+ // Arrow keys with optional CSI parameters (the Kitty protocol adds modifier and
102
+ // event fields), plus the vi-style aliases and space.
103
+ const SCROLL_KEY_RE = /\x1b\[([\d;:]*)([AB])|([kj ])/g;
104
+
105
+ /**
106
+ * Lines to scroll for an input chunk: negative is up, 0 means "not a scroll key".
107
+ *
108
+ * Holding a key down repeats it faster than the TUI hands over chunks, so one
109
+ * chunk can carry several presses — counting them all keeps a held arrow
110
+ * scrolling smoothly instead of moving a single line per chunk.
111
+ */
112
+ function scrollStep(data: string): number {
113
+ let step = 0;
114
+ SCROLL_KEY_RE.lastIndex = 0;
115
+ for (let m = SCROLL_KEY_RE.exec(data); m; m = SCROLL_KEY_RE.exec(data)) {
116
+ // Kitty key-release events (event type 3) would otherwise count twice.
117
+ if (m[1] && m[1].endsWith(":3")) continue;
118
+ switch (m[2] ?? m[3]) {
119
+ case "A":
120
+ case "k":
121
+ step -= 1;
122
+ break;
123
+ case "B":
124
+ case "j":
125
+ step += 1;
126
+ break;
127
+ case " ":
128
+ step += 10;
129
+ break;
130
+ }
131
+ }
132
+ return step;
133
+ }
134
+
135
+ /**
136
+ * Render one line of the report's markdown subset — `**Label:** value`,
137
+ * whole-line `*italics*` for hints, `- ` bullets and `` `code` `` — giving
138
+ * labels, values and amounts distinct colours.
139
+ */
140
+ function renderReportLine(raw: string, width: number, theme: Theme): string[] {
141
+ const plain = raw;
142
+ if (!plain.trim()) return [""];
143
+
144
+ const indent = plain.startsWith("- ") ? " " : " ";
145
+ const available = width - indent.length - 2;
146
+
147
+ // Hint text: "*…*"
148
+ if (/^\*[^*].*\*$/.test(plain)) {
149
+ return wrapSpans([{ text: plain.slice(1, -1), color: "dim", italic: true }], available, indent, theme);
150
+ }
151
+
152
+ // Bullet: "- Tariff: $50.31 of $70.00" or a plain item like "- claude-opus-5"
153
+ if (plain.startsWith("- ")) {
154
+ const [, key, rest] = plain.slice(2).match(/^([^:]+:)?\s*(.*)$/s) ?? [];
155
+ const spans: Span[] = [{ text: "-", color: "dim" }];
156
+ if (key) spans.push({ text: key, color: "muted" });
157
+ // A bullet without a label carries the content itself, so it stays readable
158
+ spans.push(...valueSpans(rest ?? "", key ? "muted" : "text"));
159
+ return wrapSpans(spans, available, indent, theme);
160
+ }
161
+
162
+ // Heading with a label: "**Balance:** $5.00 remaining"
163
+ const labelled = plain.match(/^\*\*(.+?)\*\*\s*(.*)$/s);
164
+ if (labelled) {
165
+ const [, label, rest] = labelled;
166
+ return wrapSpans(
167
+ [{ text: label, color: "accent", bold: true }, ...valueSpans(rest, "muted")],
168
+ available,
169
+ indent,
170
+ theme,
171
+ );
172
+ }
173
+
174
+ // Plain body text (the model list) — full contrast, it is meant to be read
175
+ return wrapSpans(valueSpans(plain, "text"), available, indent, theme);
176
+ }
177
+
178
+ export default async function (pi: ExtensionAPI) {
179
+ // Clean stale provider entries from ~/.pi/agent/models.json (left by old pi-junie setup)
180
+ await cleanOldModelsJson();
181
+
182
+ // Start proxy on ephemeral port (OS assigns a free port)
183
+ const { server, port } = await startServer();
184
+
185
+ const oauth = {
186
+ name: "JetBrains Junie",
187
+ login: junieLogin,
188
+ refreshToken: junieRefreshToken,
189
+ getApiKey: (cred: { access: string }) => cred.access,
190
+ };
191
+
192
+ // Single provider — OpenAI models inherit provider-level api/baseUrl,
193
+ // Claude models override per-model (api + baseUrl).
194
+ pi.registerProvider("junie", {
195
+ name: "JetBrains Junie",
196
+ baseUrl: `http://localhost:${port}/v1`,
197
+ api: "openai-completions",
198
+ authHeader: true,
199
+ oauth,
200
+ models: [
201
+ ...buildProviderModels("openai", port),
202
+ ...buildProviderModels("claude", port),
203
+ ...buildProviderModels("grok", port),
204
+ ...buildProviderModels("gemini", port),
205
+ ],
206
+ });
207
+
208
+ // Balance tracking after each turn
209
+ let startBalance: number | undefined;
210
+
211
+ // 100,000 Grazie credits = 1 USD (from ai.grazie.utils.mpp.money.Credit.CREDITS_IN_DOLLAR)
212
+ const CREDITS_PER_USD = 100_000;
213
+
214
+ function creditsToUsd(credits: number): number {
215
+ return credits / CREDITS_PER_USD;
216
+ }
217
+
218
+ function formatDollars(value: number): string {
219
+ return `$${value.toFixed(2)}`;
220
+ }
221
+
222
+ function formatDate(ms: number | undefined): string | undefined {
223
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return undefined;
224
+ return new Date(ms).toISOString().slice(0, 10);
225
+ }
226
+
227
+ /** Grazie reports credits; /auth/test reports plain USD for some licence types. */
228
+ function makeAmountFormatter(balanceUnit: unknown) {
229
+ const isCredits = balanceUnit === "CREDITS";
230
+ return (value: number | undefined): string | undefined =>
231
+ typeof value === "number" && Number.isFinite(value)
232
+ ? formatDollars(isCredits ? creditsToUsd(value) : value)
233
+ : undefined;
234
+ }
235
+
236
+ /** The status line only makes sense while a Junie model is doing the work. */
237
+ function isJunieModel(model: { provider?: string } | undefined): boolean {
238
+ return model?.provider === "junie";
239
+ }
240
+
241
+ // Handlers fire-and-forget syncStatus so the model picker is not blocked on
242
+ // Grazie. A fresh {} token per sync means only the latest run may apply;
243
+ // older in-flight fetches still finish but their result is dropped.
244
+ let statusToken: object | undefined;
245
+
246
+ type BalanceSnapshot = { balanceLeft: number; balanceUnit: unknown };
247
+
248
+ async function fetchBalance(ctx: ExtensionContext): Promise<BalanceSnapshot | undefined> {
249
+ try {
250
+ // The proxy can reuse the auth header of the last chat request, but
251
+ // before the first turn there is none — so resolve the key like /junie.
252
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider("junie");
253
+ const res = await fetch(`http://localhost:${port}/junie/balance`, {
254
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
255
+ });
256
+ if (!res.ok) return;
257
+ const { balanceLeft, balanceUnit } = await res.json();
258
+ if (typeof balanceLeft !== "number") return;
259
+ return { balanceLeft, balanceUnit };
260
+ } catch {
261
+ return; // best-effort — don't spam errors for a status line
262
+ }
263
+ }
264
+
265
+ function applyBalanceStatus(ctx: ExtensionContext, { balanceLeft, balanceUnit }: BalanceSnapshot) {
266
+ startBalance ??= balanceLeft;
267
+ const used = startBalance - balanceLeft;
268
+ const money = makeAmountFormatter(balanceUnit);
269
+ ctx.ui.setStatus(
270
+ "junie",
271
+ `Junie: ${money(balanceLeft)} left · −${money(used)} this session`,
272
+ );
273
+ }
274
+
275
+ /** A status set with setStatus() is sticky, so leaving Junie has to clear it. */
276
+ function clearStatus(ctx: ExtensionContext) {
277
+ ctx.ui.setStatus("junie", undefined);
278
+ }
279
+
280
+ async function syncStatus(ctx: ExtensionContext, model = ctx.model) {
281
+ const token = {};
282
+ statusToken = token;
283
+
284
+ if (!isJunieModel(model)) {
285
+ clearStatus(ctx);
286
+ return;
287
+ }
288
+
289
+ const snap = await fetchBalance(ctx);
290
+ if (!snap || statusToken !== token) return;
291
+ try {
292
+ applyBalanceStatus(ctx, snap);
293
+ } catch {
294
+ // ctx is stale after session replacement/reload while the fetch was
295
+ // in flight — the new instance owns the status line now, drop it.
296
+ }
297
+ }
298
+
299
+ pi.on("session_start", (event, ctx) => {
300
+ // "this session" is a per-session delta, so a different session starts over
301
+ if (event.reason !== "startup" && event.reason !== "reload") startBalance = undefined;
302
+ void syncStatus(ctx);
303
+ });
304
+
305
+ // Fires for /model, the model picker and restore — the only signal for
306
+ // switching to or away from Junie.
307
+ pi.on("model_select", (event, ctx) => {
308
+ void syncStatus(ctx, event.model);
309
+ });
310
+
311
+ pi.on("turn_end", (_event, ctx) => {
312
+ void syncStatus(ctx);
313
+ });
314
+
315
+ /**
316
+ * Report text that can be replaced while the overlay is already on screen,
317
+ * so the balance round-trip does not have to be awaited before showing
318
+ * anything.
319
+ */
320
+ function createLiveReport(initial: string) {
321
+ let markdown = initial;
322
+ let requestRender: (() => void) | undefined;
323
+
324
+ return {
325
+ get markdown() {
326
+ return markdown;
327
+ },
328
+ set(next: string) {
329
+ markdown = next;
330
+ requestRender?.();
331
+ },
332
+ /** Called by the overlay once — and cleared again when it closes, so a
333
+ * late arriving response cannot poke a component that is already gone. */
334
+ bind(render: (() => void) | undefined) {
335
+ requestRender = render;
336
+ },
337
+ };
338
+ }
339
+
340
+ type LiveReport = ReturnType<typeof createLiveReport>;
341
+
342
+ /** Show a report in a dismissible overlay, outside the conversation. */
343
+ async function showOverlay(ctx: ExtensionCommandContext, report: LiveReport) {
344
+ // Rendered by hand rather than with pi-tui's Markdown component: pi-tui
345
+ // lives in pi's own nested node_modules and is not resolvable from an
346
+ // installed extension, so importing it would break at runtime.
347
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
348
+ report.bind(() => tui.requestRender());
349
+
350
+ // The TUI does not clip a component to the terminal — anything too tall
351
+ // just pushes the conversation off-screen. So the report scrolls itself.
352
+ let offset = 0;
353
+
354
+ return {
355
+ render(width: number) {
356
+ const inner = Math.max(20, width - 2);
357
+ const body = report.markdown.split("\n").flatMap((raw) => renderReportLine(raw, inner, theme));
358
+
359
+ const viewport = Math.max(5, (process.stdout.rows ?? 24) - 10);
360
+ const maxOffset = Math.max(0, body.length - viewport);
361
+ offset = Math.min(offset, maxOffset);
362
+
363
+ const rule = theme.fg("accent", "─".repeat(width));
364
+ const hint = maxOffset > 0
365
+ ? ` ↑/↓ to scroll (${offset + 1}-${Math.min(offset + viewport, body.length)} of ${body.length}) · Enter/Esc to close`
366
+ : " Press Enter or Esc to close";
367
+
368
+ return [rule, ...body.slice(offset, offset + viewport), theme.fg("dim", hint), rule];
369
+ },
370
+ invalidate() {},
371
+ handleInput(data: string) {
372
+ if (isEscapeKey(data) || isEnterKey(data)) {
373
+ done(undefined);
374
+ return;
375
+ }
376
+ const step = scrollStep(data);
377
+ if (step !== 0) {
378
+ offset = Math.max(0, offset + step);
379
+ tui.requestRender();
380
+ }
381
+ },
382
+ };
383
+ });
384
+
385
+ report.bind(undefined);
386
+ }
387
+
388
+ /** The lines that need no network — everything the proxy already knows. */
389
+ function reportHeader(): string[] {
390
+ const diag = getProxyDiagnostics();
391
+ const lines = [
392
+ `**Junie Bridge** ${PLUGIN_VERSION} — running on port ${port}`,
393
+ `**HTTP Proxy:** ${diag.proxy ?? "none (direct connection)"}`,
394
+ ];
395
+ if (diag.proxy) {
396
+ lines.push(`**Proxy Auth:** ${diag.auth}`);
397
+ }
398
+ return lines;
399
+ }
400
+
401
+ async function buildReport(ctx: ExtensionCommandContext, wantsConnTest: boolean): Promise<string> {
402
+ try {
403
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider("junie");
404
+ const balanceHeaders: Record<string, string> = {};
405
+ if (apiKey) balanceHeaders["Authorization"] = `Bearer ${apiKey}`;
406
+
407
+ // The balance round-trip dominates the command's latency, so everything
408
+ // it doesn't depend on is fetched alongside it rather than after it.
409
+ const [balanceRes, modelsRes, testRes] = await Promise.all([
410
+ fetch(`http://localhost:${port}/junie/balance`, { headers: balanceHeaders }),
411
+ fetch(`http://localhost:${port}/v1/models`),
412
+ wantsConnTest ? fetch(`http://localhost:${port}/junie/test`) : undefined,
413
+ ]);
414
+
415
+ // Read the body either way — on failure it carries the reason
416
+ const balanceInfo = await balanceRes.json().catch(() => null);
417
+ const modelsInfo = modelsRes.ok ? await modelsRes.json() : null;
418
+ const testInfo = testRes?.ok ? await testRes.json().catch(() => null) : null;
419
+
420
+ const lines = reportHeader();
421
+ lines.push("");
422
+
423
+ if (balanceInfo?.balanceLeft != null) {
424
+ const money = makeAmountFormatter(balanceInfo.balanceUnit);
425
+ lines.push(`**Balance:** ${money(balanceInfo.balanceLeft)} remaining`);
426
+
427
+ // Detailed split — only present when the Grazie QuotaAPI answered
428
+ const quota = balanceInfo.quota;
429
+ if (quota?.tariff) {
430
+ const refill = formatDate(quota.refill?.next);
431
+ lines.push(
432
+ `- Tariff: ${money(quota.tariff.available)} of ${money(quota.tariff.maximum)}` +
433
+ (refill ? ` · refills ${refill}` : ""),
434
+ );
435
+ }
436
+ if (quota?.topUp?.maximum) {
437
+ lines.push(`- Top-up: ${money(quota.topUp.available)} of ${money(quota.topUp.maximum)}`);
438
+ }
439
+
440
+ if (startBalance != null) {
441
+ lines.push(`**Session usage:** −${money(startBalance - balanceInfo.balanceLeft)}`);
442
+ }
443
+
444
+ if (balanceInfo.licenseType) {
445
+ lines.push("");
446
+ lines.push(`**License type:** \`${balanceInfo.licenseType}\``);
447
+ const until = formatDate(quota?.until);
448
+ if (until) lines.push(`- Valid until ${until}`);
449
+ if (balanceInfo.licenseType === "TRIAL") {
450
+ lines.push(
451
+ "*Unexpected license type? Junie may hand out free trial credits on top of your paid subscription." +
452
+ " Once your free credits are spent your paid quota should display.*",
453
+ );
454
+ }
455
+ }
456
+ } else {
457
+ const reason = balanceInfo?.error?.message ?? "no response from the proxy";
458
+ lines.push(`**Balance:** unavailable — ${reason}`);
459
+ lines.push("*If this persists, run /login to re-authenticate.*");
460
+ }
461
+
462
+ if (testInfo) {
463
+ lines.push("");
464
+ lines.push("**Connectivity:**");
465
+ for (const [name, t] of Object.entries(testInfo.tests) as [string, any][]) {
466
+ const icon = t.ok ? "+" : "!";
467
+ lines.push(`- [${icon}] ${name}: ${t.ok ? `ok${t.status ? ` (${t.status})` : ""}` : t.error}`);
468
+ }
469
+ }
470
+
471
+ if (modelsInfo?.data) {
472
+ lines.push("");
473
+ lines.push(`**Models** (${modelsInfo.data.length}):`);
474
+ for (const m of modelsInfo.data) {
475
+ lines.push(`- ${m.id}`);
476
+ }
477
+ }
478
+
479
+ return lines.join("\n");
480
+ } catch (e) {
481
+ return [
482
+ ...reportHeader(),
483
+ "",
484
+ `**Error:** ${e instanceof Error ? e.message : String(e)}`,
485
+ ].join("\n");
486
+ }
487
+ }
488
+
489
+ // /junie command for debugging and status
490
+ pi.registerCommand("junie", {
491
+ description: "Show Junie proxy status, balance, and connectivity info",
492
+ async handler(args, ctx) {
493
+ const wantsConnTest = args?.trim() === "test" || getProxyDiagnostics().proxy != null;
494
+ const pending = buildReport(ctx, wantsConnTest);
495
+
496
+ // pi.sendMessage() would be simpler, but every custom message is converted
497
+ // into a user message for the LLM (convertToLlm in pi's core/messages) —
498
+ // the `display` flag only controls TUI rendering. A status dump has no
499
+ // business in the context window, so it goes into a dismissible overlay
500
+ // instead. Without a TUI (RPC/print mode) there is nothing to show it in,
501
+ // so the message is the fallback rather than losing the output entirely.
502
+ if (!ctx.hasUI) {
503
+ pi.sendMessage({ customType: "junie-status", content: await pending, display: true });
504
+ return;
505
+ }
506
+
507
+ // The balance call goes upstream to Grazie and takes a second or two.
508
+ // Waiting for it before opening the overlay makes the command feel stuck,
509
+ // so the overlay opens on the local facts and fills itself in.
510
+ const placeholder = [...reportHeader(), "", "**Balance:** loading…"];
511
+ if (wantsConnTest) placeholder.push("", "**Connectivity:** loading…");
512
+ placeholder.push("", "**Models:** loading…");
513
+
514
+ const report = createLiveReport(placeholder.join("\n"));
515
+ void pending.then((markdown) => report.set(markdown));
516
+
517
+ await showOverlay(ctx, report);
518
+ },
519
+ });
520
+
521
+ // Shutdown: close proxy when Pi exits
522
+ pi.on("session_shutdown", async () => {
523
+ server.close();
524
+ });
525
+ }