pi-jar 0.2.1 → 0.2.3

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/src/goal-loop.ts CHANGED
@@ -5,6 +5,8 @@ import type { Goal, GoalStore } from "./goals.ts";
5
5
  import type { ModelRoleManager } from "./model-roles.ts";
6
6
  import { isSafePlanCommand } from "./plan-utils.ts";
7
7
  import type { Todo, TodoStore } from "./tasks.ts";
8
+ import { setToolActive } from "./tool-activation.ts";
9
+ import { projectWorkflowMessages } from "./workflow-context.ts";
8
10
 
9
11
  export const GOAL_TOOL = "jar_goal";
10
12
  export const DEFAULT_GOAL_ROUNDS = 8;
@@ -262,36 +264,23 @@ export class GoalLoop {
262
264
 
263
265
  this.pi.on("before_agent_start", async (_event, ctx) => {
264
266
  const goal = this.goal();
267
+ // jar_goal is only useful while a goal runs; inactive, it costs no schema or rules tokens.
268
+ setToolActive(this.pi, GOAL_TOOL, !!goal && goal.status !== "complete");
265
269
  if (!goal || goal.status === "complete") return;
266
270
  if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
267
271
  return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
268
272
  });
269
273
 
270
- // Keep only the newest goal context and continuation. Context rewrites apply to a single LLM
271
- // call, so prune on every call; scan in place and allocate only when something is stale.
274
+ // Never drop an already-sent goal prompt mid-history while the goal runs: that would re-bill the
275
+ // previous run uncached. Repeated identical context is sent once; a finished goal drops all.
272
276
  this.pi.on("context", async (event) => {
273
277
  const goal = this.goal();
274
278
  const active = !!goal && goal.status !== "complete";
275
- let latestContext = -1;
276
- let latestContinuation = -1;
277
- let needsPrune = false;
278
- for (let index = event.messages.length - 1; index >= 0; index--) {
279
- const type = (event.messages[index] as { customType?: string }).customType;
280
- if (type === CONTEXT_TYPE) {
281
- if (!active || latestContext >= 0) needsPrune = true;
282
- else latestContext = index;
283
- } else if (type === CONTINUATION_TYPE) {
284
- if (!active || latestContinuation >= 0) needsPrune = true;
285
- else latestContinuation = index;
286
- }
287
- }
288
- if (!needsPrune) return;
289
- return { messages: event.messages.filter((raw, index) => {
290
- const type = (raw as { customType?: string }).customType;
291
- if (type === CONTEXT_TYPE) return active && index === latestContext;
292
- if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
293
- return true;
294
- }) };
279
+ const messages = projectWorkflowMessages(event.messages, {
280
+ [CONTEXT_TYPE]: { keep: active, dedupe: true },
281
+ [CONTINUATION_TYPE]: { keep: active }
282
+ });
283
+ if (messages) return { messages };
295
284
  });
296
285
 
297
286
  this.pi.on("agent_before_settle", async (event, ctx) => {
@@ -12,7 +12,10 @@ const DEFAULT_RESERVE = 16_384;
12
12
  export interface InfoPanelDeps {
13
13
  side: SideUsage;
14
14
  quota(ctx: ExtensionContext): Quota | undefined;
15
+ quotaFailure(ctx: ExtensionContext): { error: string; retryInMs: number } | undefined;
15
16
  quotaEnabled(): boolean;
17
+ /** Start a limit lookup when nothing fresh is cached: opening `/usage` is an explicit request. */
18
+ refreshQuota(ctx: ExtensionContext): void;
16
19
  }
17
20
 
18
21
  function reserveTokens(ctx: ExtensionContext): number {
@@ -61,10 +64,12 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
61
64
  { name: "Usage", render: (width, fg) => {
62
65
  usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
63
66
  const quota = deps.quota(ctx);
67
+ const quotaFailure = quota ? undefined : deps.quotaFailure(ctx);
64
68
  return usageLines({
65
69
  stats: usage,
66
70
  ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
67
71
  ...(quota ? { quota } : {}),
72
+ ...(quotaFailure ? { quotaFailure } : {}),
68
73
  quotaEnabled: deps.quotaEnabled(), now: Date.now()
69
74
  }, width, fg);
70
75
  } },
@@ -78,6 +83,7 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
78
83
  const open = async (ctx: ExtensionContext, tab: number) => {
79
84
  try {
80
85
  const pages = tabs(ctx);
86
+ if (tab === 0) deps.refreshQuota(ctx);
81
87
  if (ctx.hasUI && ctx.mode === "tui") await openPanel(ctx, pages, tab);
82
88
  else ctx.ui.notify(pages[tab]!.render(100, plain).join("\n"), "info");
83
89
  } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
@@ -153,6 +153,30 @@ export function describeRole(row: RoleRow): string {
153
153
  return row.role + " · " + (row.spec?.startsWith("@") ? row.spec + " → " : "") + target + (row.scope === "project" ? " · project" : "");
154
154
  }
155
155
 
156
+ /** Delegated roles meant for a cheaper model than the main session: scouts discover, reviewers check. */
157
+ export const ECONOMY_ROLES = ["scout", "reviewer"] as const;
158
+
159
+ /**
160
+ * Economy roles whose subagents run on the main session model although a cheaper model is available, as
161
+ * jar_delegate picks it: the role's first available candidate, then the default role's, then the current
162
+ * model. The fix names @smol when the Fast role already points at a cheaper available model.
163
+ */
164
+ export function premiumRoleWarnings(roles: Pick<ModelRoleManager, "resolveCandidates">, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): string[] {
165
+ const main = ctx.model;
166
+ if (!main) return [];
167
+ const mainId = main.provider + "/" + main.id;
168
+ const mainPrice = main.cost.input + main.cost.output;
169
+ const models = ctx.modelRegistry.getAvailable();
170
+ const cheaper = new Set(models.filter((model) => model.cost.input + model.cost.output < mainPrice).map((model) => model.provider + "/" + model.id));
171
+ // Without a cheaper option there is nothing to recommend: a main model that is already economical is fine.
172
+ if (!cheaper.size) return [];
173
+ const available = new Set(models.map((model) => model.provider + "/" + model.id));
174
+ const pick = (role: string) => roles.resolveCandidates(role).map((item) => item.provider + "/" + item.model).find((id) => available.has(id));
175
+ const fix = cheaper.has(pick("smol") ?? "") ? "@smol" : "provider/model";
176
+ return ECONOMY_ROLES.filter((role) => (pick(role) ?? pick("default") ?? mainId) === mainId)
177
+ .map((role) => `⚠ ${role} subagents run on the main model ${mainId}; use a mini/haiku-class model: /roles set ${role} ${fix}`);
178
+ }
179
+
156
180
  export class ModelRoleManager {
157
181
  private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
158
182
  private project: RoleConfig = { version: 2, roles: {} };
@@ -392,8 +416,8 @@ export class ModelRoleManager {
392
416
  const verb = (parts[0] ?? "").toLowerCase();
393
417
  if (!verb || verb === "list") {
394
418
  if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
395
- ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
396
- ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
419
+ ctx.ui.notify([...this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
420
+ ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")), ...premiumRoleWarnings(this, ctx)].join("\n"), "info");
397
421
  return;
398
422
  }
399
423
  if (verb === "fallback") {
package/src/perf.ts CHANGED
@@ -185,6 +185,9 @@ export class RenderScheduler {
185
185
 
186
186
  /** Mid-turn context recomputation happens at most this often; boundaries refresh at once. */
187
187
  export const CONTEXT_SAMPLE_MS = 750;
188
+ /** Costly samples widen the window to keep mid-turn sampling near a 1% duty cycle, up to this cap. */
189
+ export const CONTEXT_SAMPLE_MAX_MS = 5000;
190
+ const CONTEXT_SAMPLE_DUTY = 100;
188
191
  export interface ContextUsageLike { percent?: number | null }
189
192
  export interface ContextSamplingStats {
190
193
  label: string;
@@ -197,6 +200,8 @@ export interface ContextSamplingStats {
197
200
  /** Median over the latest samples. */
198
201
  medianMs?: number;
199
202
  lastSampleAgeMs?: number;
203
+ /** Current mid-turn throttle window, widened from the median sample cost. */
204
+ windowMs: number;
200
205
  dirty: boolean;
201
206
  pending: boolean;
202
207
  }
@@ -253,20 +258,31 @@ export class ContextSampler {
253
258
  const before = this.current;
254
259
  this.sample();
255
260
  if (this.current !== before) this.changed();
256
- }, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.throttleMs - this.now()));
261
+ }, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.windowMs() - this.now()));
257
262
  this.timer.unref?.();
258
263
  }
259
264
 
260
265
  stats(): ContextSamplingStats {
261
- const sorted = [...this.durations].sort((a, b) => a - b);
266
+ const median = this.medianMs();
262
267
  return {
263
268
  label: this.current, ...this.counts,
264
- ...(sorted.length ? { medianMs: sorted[Math.floor(sorted.length / 2)]! } : {}),
269
+ ...(median !== undefined ? { medianMs: median } : {}),
265
270
  ...(this.lastSampleAt !== undefined ? { lastSampleAgeMs: this.now() - this.lastSampleAt } : {}),
266
- dirty: this.stale, pending: this.timer !== undefined
271
+ windowMs: this.windowMs(), dirty: this.stale, pending: this.timer !== undefined
267
272
  };
268
273
  }
269
274
 
275
+ private medianMs(): number | undefined {
276
+ if (!this.durations.length) return undefined;
277
+ return [...this.durations].sort((a, b) => a - b)[Math.floor(this.durations.length / 2)]!;
278
+ }
279
+
280
+ /** Cost-adaptive: large sessions whose usage walk is slow sample less often mid-turn. */
281
+ private windowMs(): number {
282
+ const cost = (this.medianMs() ?? 0) * CONTEXT_SAMPLE_DUTY;
283
+ return Math.max(this.throttleMs, Math.min(CONTEXT_SAMPLE_MAX_MS, Math.round(cost)));
284
+ }
285
+
270
286
  /** Session shutdown: no timer, and no reference to the old session's context. */
271
287
  dispose(): void {
272
288
  clearTimeout(this.timer);
@@ -386,6 +402,7 @@ export function formatPerf(snapshot: PerfSnapshot): string {
386
402
  ["dirty marks", count(sampling.marks)],
387
403
  ["total time", ms(sampling.totalMs)],
388
404
  ["max time", ms(sampling.maxMs) + (sampling.medianMs !== undefined ? ` (median ${ms(sampling.medianMs)})` : "")],
405
+ ["window", ms(sampling.windowMs) + " mid-turn"],
389
406
  ["last sample age", sampling.lastSampleAgeMs !== undefined ? age(sampling.lastSampleAgeMs) : "never"],
390
407
  ["pending", sampling.pending ? "sample scheduled" : sampling.dirty ? "yes" : "no"]
391
408
  ]);
package/src/plan.ts CHANGED
@@ -11,6 +11,8 @@ import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSte
11
11
  import { cleanText } from "./status.ts";
12
12
  import { sessionBranch } from "./session-branch.ts";
13
13
  import { type TodoStore } from "./tasks.ts";
14
+ import { setToolActive } from "./tool-activation.ts";
15
+ import { projectWorkflowMessages } from "./workflow-context.ts";
14
16
 
15
17
  export const PLAN_ENTRY = "pi-jar.plan";
16
18
  export const PLAN_SUBMIT_TOOL = "jar_plan_submit";
@@ -138,7 +140,8 @@ export class PlanMode {
138
140
  this.enabled = true;
139
141
  this.pendingReview = false;
140
142
  this.reminders = 0;
141
- this.toolsBefore = this.pi.getActiveTools();
143
+ // The submit tool is active only in plan mode; never restore it with the pre-plan loadout.
144
+ this.toolsBefore = this.pi.getActiveTools().filter((name) => name !== PLAN_SUBMIT_TOOL);
142
145
  this.pi.setActiveTools(this.planTools(this.toolsBefore));
143
146
  this.onEnter?.(ctx);
144
147
  this.restoreRole = await this.roles.activateTemporary("plan", ctx);
@@ -413,34 +416,18 @@ export class PlanMode {
413
416
  });
414
417
 
415
418
  this.pi.on("before_agent_start", async () => {
416
- if (!this.enabled) return;
419
+ if (!this.enabled) { setToolActive(this.pi, PLAN_SUBMIT_TOOL, false); return; }
417
420
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
418
421
  });
419
422
 
420
- // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
421
- // context and newest reminder; when it is off, remove all stale plan prompt messages. Context
422
- // rewrites apply to a single LLM call, so this runs on every call.
423
+ // Plan context is identical every turn: send its first copy only, keeping the cached prefix
424
+ // stable. Reminders stay in place while active; leaving plan mode drops all of them.
423
425
  this.pi.on("context", async (event) => {
424
- let latestContext = -1;
425
- let latestReminder = -1;
426
- let needsPrune = false;
427
- for (let index = event.messages.length - 1; index >= 0; index--) {
428
- const type = (event.messages[index] as { customType?: string }).customType;
429
- if (type === "pi-jar.plan-context") {
430
- if (!this.enabled || latestContext >= 0) needsPrune = true;
431
- else latestContext = index;
432
- } else if (type === "pi-jar.plan-reminder") {
433
- if (!this.enabled || latestReminder >= 0) needsPrune = true;
434
- else latestReminder = index;
435
- }
436
- }
437
- if (!needsPrune) return;
438
- return { messages: event.messages.filter((raw, index) => {
439
- const type = (raw as { customType?: string }).customType;
440
- if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
441
- if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
442
- return true;
443
- }) };
426
+ const messages = projectWorkflowMessages(event.messages, {
427
+ "pi-jar.plan-context": { keep: this.enabled, dedupe: true },
428
+ "pi-jar.plan-reminder": { keep: this.enabled }
429
+ });
430
+ if (messages) return { messages };
444
431
  });
445
432
 
446
433
  this.pi.on("input", (event) => {
package/src/profile-ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { JarProfile, ProfileStore } from "./profiles.ts";
3
+ import type { JarVisualSettings } from "./settings.ts";
3
4
 
4
5
  export type ProfileAction = "create" | "switch";
5
6
 
@@ -14,11 +15,16 @@ export interface ProfileUiOptions {
14
15
  openRoles(): Promise<void>;
15
16
  }
16
17
 
18
+ /** Opt-in preset for heavy parallel work: the current settings with a larger retained subagent pool. */
19
+ export const SWARM_SETTINGS = { maxSubagents: 4 } as const satisfies Partial<JarVisualSettings>;
17
20
  const CREATE = "+ Create profile…";
21
+ const SWARM = `+ Create Swarm profile (${SWARM_SETTINGS.maxSubagents} subagents)…`;
18
22
  const FOLLOW = "Follow Pi";
19
23
 
20
- /** Name and theme are the only creation questions; roles are configured afterwards in the roles UI. */
21
- export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">): Promise<JarProfile | undefined> {
24
+ /** Name and theme are the only creation questions; roles are configured afterwards in the roles UI.
25
+ * A preset patches the copied settings once the copy is saved. */
26
+ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">,
27
+ preset?: Partial<JarVisualSettings>): Promise<JarProfile | undefined> {
22
28
  const name = await ctx.ui.input("New profile name (1–32 characters)", "");
23
29
  if (name === undefined) return undefined;
24
30
  if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
@@ -31,21 +37,30 @@ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<Prof
31
37
  }
32
38
  const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
33
39
  if (!selected) return undefined;
34
- try { return options.create(normalized, selected === FOLLOW ? "follow" : selected); }
40
+ let profile: JarProfile;
41
+ try { profile = options.create(normalized, selected === FOLLOW ? "follow" : selected); }
35
42
  catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
43
+ if (!preset) return profile;
44
+ try { return options.store.update(profile.name, { settings: { ...profile.settings, ...preset } }); }
45
+ catch (error) {
46
+ ctx.ui.notify(`Created profile ${profile.name}, but could not apply its preset: ${(error as Error).message}`, "warning");
47
+ return profile;
48
+ }
36
49
  }
37
50
 
38
51
  /** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
39
52
  export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
40
53
  if (!ctx.hasUI || ctx.mode !== "tui") return;
41
54
  const active = options.store.activeName;
55
+ let preset: Partial<JarVisualSettings> | undefined;
42
56
  if (action !== "create") {
43
57
  const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
44
- const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
58
+ const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, SWARM, ...labels.keys()]);
45
59
  if (!choice) return;
46
- if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
60
+ if (choice !== CREATE && choice !== SWARM) { await options.select(labels.get(choice)!); return; }
61
+ if (choice === SWARM) preset = SWARM_SETTINGS;
47
62
  }
48
- const profile = await promptNewProfile(ctx, options);
63
+ const profile = await promptNewProfile(ctx, options, preset);
49
64
  if (!profile) return;
50
65
  if (options.switchBlocked()) {
51
66
  ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
package/src/profiles.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
4
- import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
4
+ import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, parseContextBudget, DEFAULT_CONTEXT_BUDGET, type JarVisualSettings } from "./settings.ts";
5
5
  import { ACCENT_NAMES } from "./accent.ts";
6
6
  import { FOOTER_FIELDS } from "./footer-settings.ts";
7
7
  import { ICON_SETS } from "./icons.ts";
@@ -16,7 +16,8 @@ export function conversationStarted(entries: readonly unknown[]): boolean {
16
16
  || (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
17
17
  }
18
18
  export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
19
- for (const entry of [...entries].reverse()) {
19
+ for (let index = entries.length - 1; index >= 0; index--) {
20
+ const entry = entries[index];
20
21
  if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
21
22
  && isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
22
23
  }
@@ -40,7 +41,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
40
41
  || !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
41
42
  || typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
42
43
  || typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
43
- || (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean") || !Number.isInteger(settings.goalRounds)
44
+ || (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
45
+ || (settings.contextBudget !== undefined && !parseContextBudget(settings.contextBudget))
46
+ || (settings.readCache !== undefined && typeof settings.readCache !== "boolean") || !Number.isInteger(settings.goalRounds)
44
47
  || (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
45
48
  || !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
46
49
  || !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
@@ -49,7 +52,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
49
52
  if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
50
53
  if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
51
54
  for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
52
- return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
55
+ const contextBudget = parseContextBudget(settings.contextBudget) ?? { ...DEFAULT_CONTEXT_BUDGET };
56
+ return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false,
57
+ contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
53
58
  }
54
59
  function atomicSave(file: string, document: ProfileDocument): string {
55
60
  mkdirSync(join(file, ".."), { recursive: true });
package/src/quota.ts CHANGED
@@ -69,57 +69,70 @@ function codexAccountId(token: string): string | undefined {
69
69
  } catch { return undefined; }
70
70
  }
71
71
 
72
- /** Never persist credentials; resolve through Pi's public model registry only on explicit opt-in. */
72
+ /**
73
+ * Never persist credentials; resolve through Pi's public model registry only on explicit opt-in.
74
+ * Resolves undefined only when cancelled; every other miss throws a short, user-facing reason.
75
+ */
73
76
  export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider: string) => Promise<{ auth: { apiKey?: string }; source?: string } | undefined>, signal: AbortSignal): Promise<Quota | undefined> {
77
+ const resolved = await resolveAuth(provider);
78
+ if (signal.aborted) return undefined;
79
+ if (!resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) throw new Error("needs a subscription login (/login), not an API key");
80
+ const token = resolved.auth.apiKey;
81
+ const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
82
+ let url: string;
83
+ if (provider === "openai-codex") {
84
+ const id = codexAccountId(token);
85
+ if (!id) throw new Error("login token has no ChatGPT account id");
86
+ headers["ChatGPT-Account-Id"] = id;
87
+ url = "https://chatgpt.com/backend-api/wham/usage";
88
+ } else {
89
+ headers["anthropic-beta"] = "oauth-2025-04-20";
90
+ url = "https://api.anthropic.com/api/oauth/usage";
91
+ }
92
+ let response: Response;
74
93
  try {
75
- const resolved = await resolveAuth(provider);
76
- if (signal.aborted || !resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) return undefined;
77
- const token = resolved.auth.apiKey;
78
- const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
79
- let url: string;
80
- if (provider === "openai-codex") {
81
- const id = codexAccountId(token);
82
- if (!id) return undefined;
83
- headers["ChatGPT-Account-Id"] = id;
84
- url = "https://chatgpt.com/backend-api/wham/usage";
85
- } else {
86
- headers["anthropic-beta"] = "oauth-2025-04-20";
87
- url = "https://api.anthropic.com/api/oauth/usage";
88
- }
89
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
90
- if (!response.ok) return undefined;
91
- const data: unknown = await response.json();
92
- if (!data || typeof data !== "object") return undefined;
93
- const body = data as Record<string, unknown>;
94
- if (provider === "openai-codex") {
95
- const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
96
- const parse = (window: unknown): QuotaWindow | undefined => {
97
- if (!window || typeof window !== "object") return undefined;
98
- const item = window as Record<string, unknown>;
99
- return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
100
- };
101
- // Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
102
- // secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
103
- // to position only when the duration is absent.
104
- const quota: Quota = {};
105
- for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
106
- const value = parse(window);
107
- if (!value) continue;
108
- const seconds = (window as Record<string, unknown>).limit_window_seconds;
109
- const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
110
- if (!quota[slot]) quota[slot] = value;
111
- }
112
- return parseQuota(quota);
94
+ response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
95
+ } catch (error) {
96
+ if (signal.aborted) return undefined;
97
+ throw new Error(error instanceof Error && error.name === "TimeoutError" ? "request timed out" : "request failed: " + String(error instanceof Error ? error.message : error));
98
+ }
99
+ // The Anthropic usage endpoint is shared with Claude Code and rate-limits aggressively.
100
+ if (!response.ok) throw new Error(response.status === 429 ? "rate limited by the provider (HTTP 429)" : `HTTP ${response.status}`);
101
+ const data: unknown = await response.json().catch(() => undefined);
102
+ if (!data || typeof data !== "object") throw new Error("unreadable usage response");
103
+ const body = data as Record<string, unknown>;
104
+ let quota: Quota | undefined;
105
+ if (provider === "openai-codex") {
106
+ const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
107
+ const parse = (window: unknown): QuotaWindow | undefined => {
108
+ if (!window || typeof window !== "object") return undefined;
109
+ const item = window as Record<string, unknown>;
110
+ return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
111
+ };
112
+ // Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
113
+ // secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
114
+ // to position only when the duration is absent.
115
+ const windows: Quota = {};
116
+ for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
117
+ const value = parse(window);
118
+ if (!value) continue;
119
+ const seconds = (window as Record<string, unknown>).limit_window_seconds;
120
+ const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
121
+ if (!windows[slot]) windows[slot] = value;
113
122
  }
123
+ quota = parseQuota(windows);
124
+ } else {
125
+ // `utilization` is already a percentage (7.0 means 7%), not a fraction.
114
126
  const parse = (window: unknown): QuotaWindow | undefined => {
115
127
  if (!window || typeof window !== "object") return undefined;
116
128
  const item = window as Record<string, unknown>;
117
- const utilization = item.utilization;
118
129
  const resetsAt = typeof item.resets_at === "string" ? Date.parse(item.resets_at) : undefined;
119
- return windowValue({ used: typeof utilization === "number" && utilization <= 1 ? utilization * 100 : utilization, resetsAt });
130
+ return windowValue({ used: item.utilization, resetsAt });
120
131
  };
121
- return parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
122
- } catch { return undefined; }
132
+ quota = parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
133
+ }
134
+ if (!quota) throw new Error("no plan limits in the usage response");
135
+ return quota;
123
136
  }
124
137
 
125
138
  /**
@@ -128,7 +141,7 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
128
141
  * lookups start only from `refresh` at session boundaries.
129
142
  */
130
143
  export class QuotaCache {
131
- private entries = new Map<QuotaProvider, { value?: Quota; requestedAt: number; expiresAt: number; failures: number }>();
144
+ private entries = new Map<QuotaProvider, { value?: Quota; error?: string; requestedAt: number; expiresAt: number; failures: number }>();
132
145
  private pending = new Map<QuotaProvider, AbortController>();
133
146
  private requests = 0;
134
147
  private failures = 0;
@@ -147,6 +160,12 @@ export class QuotaCache {
147
160
  const cached = this.entries.get(provider);
148
161
  return cached && cached.expiresAt > now ? cached.value : undefined;
149
162
  }
163
+ /** Why the last lookup failed and when the next may start; undefined while pending, fresh, or never tried. */
164
+ failure(provider: string | undefined, now: number): { error: string; retryInMs: number } | undefined {
165
+ if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled || this.pending.has(provider)) return undefined;
166
+ const cached = this.entries.get(provider);
167
+ return cached?.error !== undefined && cached.expiresAt > now ? { error: cached.error, retryInMs: cached.expiresAt - now } : undefined;
168
+ }
150
169
  /** Start one background lookup when nothing fresh is cached and no retry delay is pending; true if it started. */
151
170
  refresh(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): boolean {
152
171
  if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled) return false;
@@ -167,15 +186,18 @@ export class QuotaCache {
167
186
  if (this.pending.get(provider) === controller) this.pending.delete(provider);
168
187
  return;
169
188
  }
170
- // A rejected lookup is a failure like an empty one: both back off instead of retrying at once.
171
- void this.fetcher(provider, controller.signal).catch(() => undefined).then((value) => {
189
+ // A rejected lookup backs off instead of retrying at once; its reason is kept for the usage panel.
190
+ void this.fetcher(provider, controller.signal).then(
191
+ (value) => ({ value, error: value ? undefined : "no usage returned" }),
192
+ (error: unknown) => ({ value: undefined, error: error instanceof Error ? error.message : String(error) })
193
+ ).then(({ value, error }) => {
172
194
  if (controller.signal.aborted) return;
173
195
  const failures = value ? 0 : (this.entries.get(provider)?.failures ?? 0) + 1;
174
196
  if (!value) this.failures++;
175
197
  const ttl = value ? QUOTA_TTL_MS : Math.min(QUOTA_MAX_BACKOFF_MS, QUOTA_TTL_MS * 2 ** (failures - 1));
176
- this.entries.set(provider, { ...(value ? { value } : {}), requestedAt: now, expiresAt: now + ttl, failures });
177
- // An expired value was already hidden, so only a new value changes what renders show.
178
- if (value) this.changed();
198
+ this.entries.set(provider, { ...(value ? { value } : { error: error ?? "no usage returned" }), requestedAt: now, expiresAt: now + ttl, failures });
199
+ // Either outcome replaces the "loading" state renders show.
200
+ this.changed();
179
201
  }).finally(() => {
180
202
  if (this.pending.get(provider) === controller) this.pending.delete(provider);
181
203
  });
package/src/roles-ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { promptText } from "./dialogs.ts";
4
- import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
4
+ import { isRoleName, normalizeSpec, premiumRoleWarnings, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
5
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
6
 
7
7
  type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
@@ -44,6 +44,8 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
44
44
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
45
45
  let width = 80;
46
46
  const rows = () => roles.list();
47
+ // Read once per screen: every action closes the screen, and reopening it re-checks against the current model.
48
+ const warnings = premiumRoleWarnings(roles, ctx);
47
49
  const finish = (action: RoleAction) => done({ action, index: selected });
48
50
  return {
49
51
  invalidate() {},
@@ -98,7 +100,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
98
100
  const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
99
101
  const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
100
102
  .map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
101
- const body = [...header, ...info];
103
+ const body = [...header, ...warnings.map((text) => theme.fg("warning", text)), ...info];
102
104
  const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
103
105
  ...actions,
104
106
  theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")