add sub-bar usage widget extension with opencode-go support

This commit is contained in:
2026-03-12 12:30:09 +00:00
parent 9ad10a016c
commit 7a9e2b94ff
70 changed files with 14787 additions and 6 deletions
@@ -0,0 +1,535 @@
/**
* sub-core - Shared usage data core for sub-* extensions.
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import * as fs from "node:fs";
import type { Dependencies, ProviderName, SubCoreState, UsageSnapshot } from "./src/types.js";
import { getDefaultSettings, type Settings } from "./src/settings-types.js";
import type { ProviderUsageEntry } from "./src/usage/types.js";
import { createDefaultDependencies } from "./src/dependencies.js";
import { createUsageController, type UsageUpdate } from "./src/usage/controller.js";
import { fetchUsageEntries, getCachedUsageEntries } from "./src/usage/fetch.js";
import { onCacheSnapshot, onCacheUpdate, watchCacheUpdates, type Cache } from "./src/cache.js";
import { isExpectedMissingData } from "./src/errors.js";
import { getStorage } from "./src/storage.js";
import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js";
import { showSettingsUI } from "./src/settings-ui.js";
type SubCoreRequest =
| {
type?: "current";
includeSettings?: boolean;
reply: (payload: { state: SubCoreState; settings?: Settings }) => void;
}
| {
type: "entries";
force?: boolean;
reply: (payload: { entries: ProviderUsageEntry[] }) => void;
};
type SubCoreAction = {
type: "refresh" | "cycleProvider";
force?: boolean;
};
const TOOL_NAMES = {
usage: ["sub_get_usage", "get_current_usage"],
allUsage: ["sub_get_all_usage", "get_all_usage"],
} as const;
type ToolName = (typeof TOOL_NAMES)[keyof typeof TOOL_NAMES][number];
type SubCoreGlobalState = { active: boolean };
const subCoreGlobal = globalThis as typeof globalThis & { __piSubCore?: SubCoreGlobalState };
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
const result = { ...target } as T;
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = result[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
function stripUsageProvider(usage?: UsageSnapshot): Omit<UsageSnapshot, "provider"> | undefined {
if (!usage) return undefined;
const { provider: _provider, ...rest } = usage;
return rest;
}
/**
* Create the extension
*/
export default function createExtension(pi: ExtensionAPI, deps: Dependencies = createDefaultDependencies()): void {
if (subCoreGlobal.__piSubCore?.active) {
return;
}
subCoreGlobal.__piSubCore = { active: true };
let usageRefreshInterval: ReturnType<typeof setInterval> | undefined;
let statusRefreshInterval: ReturnType<typeof setInterval> | undefined;
let lastContext: ExtensionContext | undefined;
let lastUsageRefreshAt = 0;
let lastStatusRefreshAt = 0;
let settings: Settings = getDefaultSettings();
let settingsLoaded = false;
let toolsRegistered = false;
let lastState: SubCoreState = {};
let settingsSnapshot = "";
let settingsMtimeMs = 0;
let settingsDebounce: NodeJS.Timeout | undefined;
let settingsWatcher: fs.FSWatcher | undefined;
let settingsPoll: NodeJS.Timeout | undefined;
let settingsWatchStarted = false;
const controller = createUsageController(deps);
const controllerState = {
currentProvider: undefined as ProviderName | undefined,
cachedUsage: undefined as UsageSnapshot | undefined,
providerCycleIndex: 0,
};
let lastAllSnapshot = "";
let lastCurrentSnapshot = "";
const emitCurrentUpdate = (provider?: ProviderName, usage?: UsageSnapshot): void => {
lastState = { provider, usage };
const payload = JSON.stringify(lastState);
if (payload === lastCurrentSnapshot) return;
lastCurrentSnapshot = payload;
pi.events.emit("sub-core:update-current", { state: lastState });
};
const unsubscribeCacheSnapshot = onCacheSnapshot((cache: Cache) => {
const ttlMs = settings.behavior.refreshInterval * 1000;
const now = Date.now();
const entries: ProviderUsageEntry[] = [];
for (const provider of settings.providerOrder) {
const entry = cache[provider];
if (!entry || !entry.usage) continue;
if (now - entry.fetchedAt >= ttlMs) continue;
const usage = { ...entry.usage, status: entry.status };
if (usage.error && isExpectedMissingData(usage.error)) continue;
entries.push({ provider, usage });
}
const payload = JSON.stringify({ provider: controllerState.currentProvider, entries });
if (payload === lastAllSnapshot) return;
lastAllSnapshot = payload;
pi.events.emit("sub-core:update-all", {
state: { provider: controllerState.currentProvider, entries },
});
});
const unsubscribeCache = onCacheUpdate((provider, entry) => {
if (!controllerState.currentProvider || provider !== controllerState.currentProvider) return;
const usage = entry?.usage ? { ...entry.usage, status: entry.status } : undefined;
controllerState.cachedUsage = usage;
emitCurrentUpdate(controllerState.currentProvider, usage);
});
let stopCacheWatch: (() => void) | undefined;
let cacheWatchStarted = false;
const startCacheWatch = (): void => {
if (cacheWatchStarted) return;
cacheWatchStarted = true;
stopCacheWatch = watchCacheUpdates();
};
function emitUpdate(update: UsageUpdate): void {
emitCurrentUpdate(update.provider, update.usage);
}
async function refresh(
ctx: ExtensionContext,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
) {
lastContext = ctx;
ensureSettingsLoaded();
try {
await controller.refresh(ctx, settings, controllerState, emitUpdate, options);
} finally {
if (!options?.skipFetch) {
lastUsageRefreshAt = Date.now();
}
}
}
async function refreshStatus(
ctx: ExtensionContext,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
) {
lastContext = ctx;
ensureSettingsLoaded();
try {
await controller.refreshStatus(ctx, settings, controllerState, emitUpdate, options);
} finally {
if (!options?.skipFetch) {
lastStatusRefreshAt = Date.now();
}
}
}
async function cycleProvider(ctx: ExtensionContext): Promise<void> {
ensureSettingsLoaded();
await controller.cycleProvider(ctx, settings, controllerState, emitUpdate);
}
function setupRefreshInterval(): void {
if (usageRefreshInterval) {
clearInterval(usageRefreshInterval);
usageRefreshInterval = undefined;
}
if (statusRefreshInterval) {
clearInterval(statusRefreshInterval);
statusRefreshInterval = undefined;
}
const usageIntervalMs = settings.behavior.refreshInterval * 1000;
if (usageIntervalMs > 0) {
const usageTickMs = Math.min(usageIntervalMs, 10000);
usageRefreshInterval = setInterval(() => {
if (!lastContext) return;
const elapsed = lastUsageRefreshAt ? Date.now() - lastUsageRefreshAt : usageIntervalMs + 1;
if (elapsed >= usageIntervalMs) {
void refresh(lastContext);
}
}, usageTickMs);
usageRefreshInterval.unref?.();
}
const statusIntervalMs = settings.statusRefresh.refreshInterval * 1000;
if (statusIntervalMs > 0) {
const statusTickMs = Math.min(statusIntervalMs, 10000);
statusRefreshInterval = setInterval(() => {
if (!lastContext) return;
const elapsed = lastStatusRefreshAt ? Date.now() - lastStatusRefreshAt : statusIntervalMs + 1;
if (elapsed >= statusIntervalMs) {
void refreshStatus(lastContext);
}
}, statusTickMs);
statusRefreshInterval.unref?.();
}
}
function applySettingsPatch(patch: Partial<Settings>): void {
ensureSettingsLoaded();
settings = deepMerge(settings, patch);
saveSettings(settings);
setupRefreshInterval();
pi.events.emit("sub-core:settings:updated", { settings });
}
function readSettingsFile(): string | undefined {
try {
return fs.readFileSync(SETTINGS_PATH, "utf-8");
} catch {
return undefined;
}
}
function applySettingsFromDisk(): void {
clearSettingsCache();
settings = loadSettings();
registerToolsFromSettings(settings);
setupRefreshInterval();
pi.events.emit("sub-core:settings:updated", { settings });
if (lastContext) {
void refresh(lastContext, { allowStaleCache: true, skipFetch: true });
void refreshStatus(lastContext, { allowStaleCache: true, skipFetch: true });
}
}
function refreshSettingsSnapshot(): void {
const content = readSettingsFile();
if (!content || content === settingsSnapshot) return;
try {
JSON.parse(content);
} catch {
return;
}
settingsSnapshot = content;
applySettingsFromDisk();
}
function checkSettingsFile(): void {
try {
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
if (!stat || !stat.mtimeMs) return;
if (stat.mtimeMs === settingsMtimeMs) return;
settingsMtimeMs = stat.mtimeMs;
refreshSettingsSnapshot();
} catch {
// Ignore missing files
}
}
function scheduleSettingsRefresh(): void {
if (settingsDebounce) clearTimeout(settingsDebounce);
settingsDebounce = setTimeout(() => checkSettingsFile(), 200);
}
function startSettingsWatch(): void {
if (settingsWatchStarted) return;
settingsWatchStarted = true;
if (!settingsSnapshot) {
const content = readSettingsFile();
if (content) {
settingsSnapshot = content;
try {
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs;
} catch {
// Ignore
}
}
}
try {
settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh);
settingsWatcher.unref?.();
} catch {
settingsWatcher = undefined;
}
settingsPoll = setInterval(() => checkSettingsFile(), 2000);
settingsPoll.unref?.();
}
async function getEntries(force?: boolean): Promise<ProviderUsageEntry[]> {
ensureSettingsLoaded();
const enabledProviders = controller.getEnabledProviders(settings);
if (enabledProviders.length === 0) return [];
if (force) {
return fetchUsageEntries(deps, settings, enabledProviders, { force: true });
}
return getCachedUsageEntries(enabledProviders, settings);
}
const registerUsageTool = (name: ToolName): void => {
pi.registerTool({
name,
label: "Sub Usage",
description: "Refresh and return the latest subscription usage snapshot.",
parameters: Type.Object({
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { force } = params as { force?: boolean };
await refresh(ctx, { force: force ?? true });
const payload = { provider: lastState.provider, usage: stripUsageProvider(lastState.usage) };
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
};
},
});
};
const registerAllUsageTool = (name: ToolName): void => {
pi.registerTool({
name,
label: "Sub All Usage",
description: "Refresh and return usage snapshots for all enabled providers.",
parameters: Type.Object({
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { force } = params as { force?: boolean };
const entries = await getEntries(force ?? true);
const payload = entries.map((entry) => ({
provider: entry.provider,
usage: stripUsageProvider(entry.usage),
}));
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: { entries: payload },
};
},
});
};
function registerToolsFromSettings(nextSettings: Settings): void {
if (toolsRegistered) return;
const usageToolEnabled = nextSettings.tools?.usageTool ?? false;
const allUsageToolEnabled = nextSettings.tools?.allUsageTool ?? false;
if (usageToolEnabled) {
for (const name of TOOL_NAMES.usage) {
registerUsageTool(name);
}
}
if (allUsageToolEnabled) {
for (const name of TOOL_NAMES.allUsage) {
registerAllUsageTool(name);
}
}
toolsRegistered = true;
}
function ensureSettingsLoaded(): void {
if (settingsLoaded) return;
settings = loadSettings();
settingsLoaded = true;
registerToolsFromSettings(settings);
setupRefreshInterval();
const watchTimer = setTimeout(() => {
startCacheWatch();
startSettingsWatch();
}, 0);
watchTimer.unref?.();
}
pi.registerCommand("sub-core:settings", {
description: "Open sub-core settings",
handler: async (_args, ctx) => {
ensureSettingsLoaded();
const handleSettingsChange = async (updatedSettings: Settings) => {
applySettingsPatch(updatedSettings);
if (lastContext) {
await refresh(lastContext);
}
};
const newSettings = await showSettingsUI(ctx, handleSettingsChange);
settings = newSettings;
applySettingsPatch(newSettings);
if (lastContext) {
await refresh(lastContext);
}
},
});
pi.events.on("sub-core:request", async (payload) => {
ensureSettingsLoaded();
const request = payload as SubCoreRequest;
if (request.type === "entries") {
const entries = await getEntries(request.force);
if (lastContext && settings.statusRefresh.refreshInterval > 0) {
await refreshStatus(lastContext, { force: request.force });
}
request.reply({ entries });
return;
}
request.reply({
state: lastState,
settings: request.includeSettings ? settings : undefined,
});
});
pi.events.on("sub-core:settings:patch", (payload) => {
const patch = (payload as { patch?: Partial<Settings> }).patch;
if (!patch) return;
applySettingsPatch(patch);
if (lastContext) {
void refresh(lastContext);
}
});
pi.events.on("sub-core:action", (payload) => {
const action = payload as SubCoreAction;
if (!lastContext) return;
switch (action.type) {
case "refresh":
void refresh(lastContext, { force: action.force });
break;
case "cycleProvider":
void cycleProvider(lastContext);
break;
}
});
pi.on("session_start", async (_event, ctx) => {
lastContext = ctx;
ensureSettingsLoaded();
void refresh(ctx, { allowStaleCache: true, skipFetch: true });
void refreshStatus(ctx, { allowStaleCache: true, skipFetch: true });
pi.events.emit("sub-core:ready", { state: lastState, settings });
});
pi.on("turn_start", async (_event, ctx) => {
if (settings.behavior.refreshOnTurnStart) {
await refresh(ctx);
}
if (settings.statusRefresh.refreshOnTurnStart) {
await refreshStatus(ctx);
}
});
pi.on("tool_result", async (_event, ctx) => {
if (settings.behavior.refreshOnToolResult) {
await refresh(ctx, { force: true });
}
if (settings.statusRefresh.refreshOnToolResult) {
await refreshStatus(ctx, { force: true });
}
});
pi.on("turn_end", async (_event, ctx) => {
await refresh(ctx, { force: true });
});
pi.on("session_switch", async (_event, ctx) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
await refresh(ctx);
await refreshStatus(ctx);
});
pi.on("session_branch" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
await refresh(ctx);
await refreshStatus(ctx);
});
pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
controllerState.currentProvider = undefined;
controllerState.cachedUsage = undefined;
void refresh(ctx, { force: true, allowStaleCache: true });
void refreshStatus(ctx, { force: true, allowStaleCache: true });
});
pi.on("session_shutdown", async () => {
if (usageRefreshInterval) {
clearInterval(usageRefreshInterval);
usageRefreshInterval = undefined;
}
if (statusRefreshInterval) {
clearInterval(statusRefreshInterval);
statusRefreshInterval = undefined;
}
if (settingsDebounce) {
clearTimeout(settingsDebounce);
settingsDebounce = undefined;
}
if (settingsPoll) {
clearInterval(settingsPoll);
settingsPoll = undefined;
}
settingsWatcher?.close();
settingsWatcher = undefined;
settingsWatchStarted = false;
settingsSnapshot = "";
settingsMtimeMs = 0;
unsubscribeCache();
unsubscribeCacheSnapshot();
stopCacheWatch?.();
stopCacheWatch = undefined;
cacheWatchStarted = false;
lastContext = undefined;
subCoreGlobal.__piSubCore = undefined;
});
}
@@ -0,0 +1,35 @@
{
"name": "@marckrenn/pi-sub-core",
"version": "1.3.0",
"description": "Shared usage data core for pi extensions",
"keywords": [
"pi-package"
],
"type": "module",
"license": "MIT",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"check": "tsc --noEmit",
"check:watch": "tsc --noEmit --watch",
"test": "tsx test/all.test.ts"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.2",
"typescript": "^5.8.0"
},
"dependencies": {
"@marckrenn/pi-sub-shared": "^1.3.0"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
}
}
@@ -0,0 +1,489 @@
/**
* Cache management for sub-bar
* Shared cache across all pi instances to avoid redundant API calls
*/
import * as path from "node:path";
import * as fs from "node:fs";
import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
import { isExpectedMissingData } from "./errors.js";
import { getStorage } from "./storage.js";
import {
getCachePath,
getCacheLockPath,
getLegacyAgentCacheLockPath,
getLegacyAgentCachePath,
getLegacyCacheLockPath,
getLegacyCachePath,
} from "./paths.js";
import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js";
/**
* Cache entry for a provider
*/
export interface CacheEntry {
fetchedAt: number;
statusFetchedAt?: number;
usage?: UsageSnapshot;
status?: ProviderStatus;
}
/**
* Cache structure
*/
export interface Cache {
[provider: string]: CacheEntry;
}
export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void;
export type CacheSnapshotListener = (cache: Cache) => void;
const cacheUpdateListeners = new Set<CacheUpdateListener>();
const cacheSnapshotListeners = new Set<CacheSnapshotListener>();
let lastCacheSnapshot: Cache | null = null;
let lastCacheContent = "";
let lastCacheMtimeMs = 0;
let legacyCacheMigrated = false;
function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void {
lastCacheSnapshot = cache;
lastCacheContent = content;
lastCacheMtimeMs = mtimeMs;
}
function resetCacheSnapshot(): void {
lastCacheSnapshot = {};
lastCacheContent = "";
lastCacheMtimeMs = 0;
}
function migrateLegacyCache(): void {
if (legacyCacheMigrated) return;
legacyCacheMigrated = true;
const storage = getStorage();
try {
const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH];
if (!storage.exists(CACHE_PATH)) {
const legacyPath = legacyCachePaths.find((path) => storage.exists(path));
if (legacyPath) {
const content = storage.readFile(legacyPath);
if (content) {
ensureCacheDir();
storage.writeFile(CACHE_PATH, content);
}
}
}
for (const legacyPath of legacyCachePaths) {
if (storage.exists(legacyPath)) {
storage.removeFile(legacyPath);
}
}
for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) {
if (storage.exists(legacyLockPath)) {
storage.removeFile(legacyLockPath);
}
}
} catch (error) {
console.error("Failed to migrate cache:", error);
}
}
export function onCacheUpdate(listener: CacheUpdateListener): () => void {
cacheUpdateListeners.add(listener);
return () => {
cacheUpdateListeners.delete(listener);
};
}
export function onCacheSnapshot(listener: CacheSnapshotListener): () => void {
cacheSnapshotListeners.add(listener);
return () => {
cacheSnapshotListeners.delete(listener);
};
}
function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void {
for (const listener of cacheUpdateListeners) {
try {
listener(provider, entry);
} catch (error) {
console.error("Failed to notify cache update:", error);
}
}
}
function emitCacheSnapshot(cache: Cache): void {
for (const listener of cacheSnapshotListeners) {
try {
listener(cache);
} catch (error) {
console.error("Failed to notify cache snapshot:", error);
}
}
}
/**
* Cache file path
*/
export const CACHE_PATH = getCachePath();
const LEGACY_CACHE_PATH = getLegacyCachePath();
const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath();
/**
* Lock file path
*/
const LOCK_PATH = getCacheLockPath();
const LEGACY_LOCK_PATH = getLegacyCacheLockPath();
const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath();
/**
* Lock timeout in milliseconds
*/
const LOCK_TIMEOUT_MS = 5000;
/**
* Ensure cache directory exists
*/
function ensureCacheDir(): void {
const storage = getStorage();
const dir = path.dirname(CACHE_PATH);
storage.ensureDir(dir);
}
/**
* Read cache from disk
*/
export function readCache(): Cache {
migrateLegacyCache();
const storage = getStorage();
try {
const cacheExists = storage.exists(CACHE_PATH);
if (!cacheExists) {
if (lastCacheMtimeMs !== 0 || lastCacheContent) {
resetCacheSnapshot();
}
return lastCacheSnapshot ?? {};
}
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) {
return lastCacheSnapshot;
}
const content = storage.readFile(CACHE_PATH);
if (!content) {
updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0);
return {};
}
if (!stat && content === lastCacheContent && lastCacheSnapshot) {
return lastCacheSnapshot;
}
try {
const parsed = JSON.parse(content) as Cache;
updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now());
return parsed;
} catch (error) {
const lastBrace = content.lastIndexOf("}");
if (lastBrace > 0) {
const trimmed = content.slice(0, lastBrace + 1);
try {
const parsed = JSON.parse(trimmed) as Cache;
if (stat) {
writeCache(parsed);
} else {
updateCacheSnapshot(parsed, trimmed, Date.now());
}
return parsed;
} catch {
// fall through to log below
}
}
console.error("Failed to read cache:", error);
}
} catch (error) {
console.error("Failed to read cache:", error);
}
return {};
}
/**
* Write cache to disk
*/
function writeCache(cache: Cache): void {
migrateLegacyCache();
const storage = getStorage();
try {
ensureCacheDir();
const content = JSON.stringify(cache, null, 2);
const cacheExists = storage.exists(CACHE_PATH);
if (cacheExists && content === lastCacheContent) {
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs);
return;
}
const tempPath = `${CACHE_PATH}.${process.pid}.tmp`;
fs.writeFileSync(tempPath, content, "utf-8");
fs.renameSync(tempPath, CACHE_PATH);
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now());
} catch (error) {
console.error("Failed to write cache:", error);
}
}
export interface CacheWatchOptions {
debounceMs?: number;
pollIntervalMs?: number;
lockRetryMs?: number;
}
export function watchCacheUpdates(options?: CacheWatchOptions): () => void {
migrateLegacyCache();
const debounceMs = options?.debounceMs ?? 250;
const pollIntervalMs = options?.pollIntervalMs ?? 5000;
const lockRetryMs = options?.lockRetryMs ?? 1000;
let debounceTimer: NodeJS.Timeout | undefined;
let pollTimer: NodeJS.Timeout | undefined;
let lockRetryPending = false;
let lastSnapshot = "";
let lastMtimeMs = 0;
let stopped = false;
const scheduleLockRetry = () => {
if (lockRetryPending || stopped) return;
lockRetryPending = true;
void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => {
lockRetryPending = false;
if (released) {
emitFromCache();
}
});
};
const emitFromCache = () => {
try {
if (fs.existsSync(LOCK_PATH)) {
scheduleLockRetry();
return;
}
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
if (!stat || !stat.mtimeMs) return;
if (stat.mtimeMs === lastMtimeMs) return;
lastMtimeMs = stat.mtimeMs;
const content = fs.readFileSync(CACHE_PATH, "utf-8");
if (content === lastSnapshot) return;
lastSnapshot = content;
const cache = JSON.parse(content) as Cache;
updateCacheSnapshot(cache, content, stat.mtimeMs);
emitCacheSnapshot(cache);
for (const [provider, entry] of Object.entries(cache)) {
emitCacheUpdate(provider as ProviderName, entry);
}
} catch {
// Ignore parse or read errors (likely mid-write)
}
};
const scheduleEmit = () => {
if (stopped) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => emitFromCache(), debounceMs);
};
let watcher: fs.FSWatcher | undefined;
try {
watcher = fs.watch(CACHE_PATH, scheduleEmit);
watcher.unref?.();
} catch {
watcher = undefined;
}
pollTimer = setInterval(() => emitFromCache(), pollIntervalMs);
pollTimer.unref?.();
return () => {
stopped = true;
if (debounceTimer) clearTimeout(debounceTimer);
if (pollTimer) clearInterval(pollTimer);
watcher?.close();
};
}
/**
* Wait for lock to be released and re-check cache
* Returns the cache entry if it became fresh while waiting
*/
async function waitForLockAndRecheck(
provider: ProviderName,
ttlMs: number,
maxWaitMs: number = 3000
): Promise<CacheEntry | null> {
const released = await waitForLockRelease(LOCK_PATH, maxWaitMs);
if (!released) {
return null;
}
const cache = readCache();
const entry = cache[provider];
if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
return null;
}
if (entry && Date.now() - entry.fetchedAt < ttlMs) {
return entry;
}
return null;
}
/**
* Get cached data for a provider if fresh, or null if stale/missing
*/
export async function getCachedData(
provider: ProviderName,
ttlMs: number,
cacheSnapshot?: Cache
): Promise<CacheEntry | null> {
const cache = cacheSnapshot ?? readCache();
const entry = cache[provider];
if (!entry) {
return null;
}
if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
return null;
}
const age = Date.now() - entry.fetchedAt;
if (age < ttlMs) {
return entry;
}
return null;
}
/**
* Fetch data with lock coordination
* Returns cached data if fresh, or executes fetchFn if cache is stale
*/
export async function fetchWithCache<T extends { usage?: UsageSnapshot; status?: ProviderStatus; statusFetchedAt?: number }>(
provider: ProviderName,
ttlMs: number,
fetchFn: () => Promise<T>,
options?: { force?: boolean }
): Promise<T> {
const forceRefresh = options?.force === true;
if (!forceRefresh) {
// Check cache first
const cached = await getCachedData(provider, ttlMs);
if (cached) {
return { usage: cached.usage, status: cached.status } as T;
}
}
// Cache is stale or forced refresh, try to acquire lock
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
if (!lockAcquired) {
// Another process is fetching, wait and re-check cache
const freshEntry = await waitForLockAndRecheck(provider, ttlMs);
if (freshEntry) {
return { usage: freshEntry.usage, status: freshEntry.status } as T;
}
// Timeout or cache still stale, fetch anyway
}
try {
// Fetch fresh data
const result = await fetchFn();
// Only cache if we got valid usage data (not just no-credentials/errors)
const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error);
const hasError = Boolean(result.usage?.error);
const shouldCache = result.usage && !hasCredentialError && !hasError;
const cache = readCache();
if (shouldCache) {
// Update cache with valid data
const fetchedAt = Date.now();
const previous = cache[provider];
const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt);
cache[provider] = {
fetchedAt,
statusFetchedAt,
usage: result.usage,
status: result.status,
};
writeCache(cache);
emitCacheUpdate(provider, cache[provider]);
emitCacheSnapshot(cache);
} else if (hasCredentialError) {
// Remove from cache if no credentials
if (cache[provider]) {
delete cache[provider];
writeCache(cache);
emitCacheUpdate(provider, undefined);
emitCacheSnapshot(cache);
}
}
return result;
} finally {
if (lockAcquired) {
releaseFileLock(LOCK_PATH);
}
}
}
export async function updateCacheStatus(
provider: ProviderName,
status: ProviderStatus,
options?: { statusFetchedAt?: number }
): Promise<void> {
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
if (!lockAcquired) {
await waitForLockRelease(LOCK_PATH, 3000);
}
try {
const cache = readCache();
const entry = cache[provider];
const statusFetchedAt = options?.statusFetchedAt ?? Date.now();
cache[provider] = {
fetchedAt: entry?.fetchedAt ?? 0,
statusFetchedAt,
usage: entry?.usage,
status,
};
writeCache(cache);
emitCacheUpdate(provider, cache[provider]);
emitCacheSnapshot(cache);
} finally {
if (lockAcquired) {
releaseFileLock(LOCK_PATH);
}
}
}
/**
* Clear cache for a specific provider or all providers
*/
export function clearCache(provider?: ProviderName): void {
const storage = getStorage();
if (provider) {
const cache = readCache();
delete cache[provider];
writeCache(cache);
} else {
try {
if (storage.exists(CACHE_PATH)) {
storage.removeFile(CACHE_PATH);
}
resetCacheSnapshot();
} catch (error) {
console.error("Failed to clear cache:", error);
}
}
}
@@ -0,0 +1,35 @@
/**
* Configuration constants for the sub-bar extension
*/
/**
* Google Workspace status API endpoint
*/
export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json";
/**
* Google product ID for Gemini in the status API
*/
export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo";
/**
* Model multipliers for Copilot request counting
* Maps model display names to their request multiplier
*/
export { MODEL_MULTIPLIERS } from "../../src/shared.js";
/**
* Timeout for API requests in milliseconds
*/
export const API_TIMEOUT_MS = 5000;
/**
* Timeout for CLI commands in milliseconds
*/
export const CLI_TIMEOUT_MS = 10000;
/**
* Interval for automatic usage refresh in milliseconds
*/
export const REFRESH_INTERVAL_MS = 60_000;
@@ -0,0 +1,37 @@
/**
* Default dependencies using real implementations
*/
import * as fs from "node:fs";
import * as os from "node:os";
import { execFileSync } from "node:child_process";
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
import type { Dependencies } from "./types.js";
/**
* Create default dependencies using Node.js APIs
*/
export function createDefaultDependencies(): Dependencies {
return {
fetch: globalThis.fetch,
readFile: (path: string) => {
try {
return fs.readFileSync(path, "utf-8");
} catch {
return undefined;
}
},
fileExists: (path: string) => {
try {
return fs.existsSync(path);
} catch {
return false;
}
},
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => {
return execFileSync(file, args, options) as string;
},
homedir: () => os.homedir(),
env: process.env,
};
}
@@ -0,0 +1,71 @@
/**
* Error utilities for the sub-bar extension
*/
import type { UsageError, UsageErrorCode } from "./types.js";
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
return { code, message, httpStatus };
}
export function noCredentials(): UsageError {
return createError("NO_CREDENTIALS", "No credentials found");
}
export function noCli(cliName: string): UsageError {
return createError("NO_CLI", `${cliName} CLI not found`);
}
export function notLoggedIn(): UsageError {
return createError("NOT_LOGGED_IN", "Not logged in");
}
export function fetchFailed(reason?: string): UsageError {
return createError("FETCH_FAILED", reason ?? "Fetch failed");
}
export function httpError(status: number): UsageError {
return createError("HTTP_ERROR", `HTTP ${status}`, status);
}
export function apiError(message: string): UsageError {
return createError("API_ERROR", message);
}
export function timeout(): UsageError {
return createError("TIMEOUT", "Request timed out");
}
/**
* Check if an error should be considered "no data available" vs actual error
* These are expected states when provider isn't configured
*/
export function isExpectedMissingData(error: UsageError): boolean {
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
return ignoreCodes.has(error.code);
}
/**
* Format error for display in the usage widget
*/
export function formatErrorForDisplay(error: UsageError): string {
switch (error.code) {
case "NO_CREDENTIALS":
return "No creds";
case "NO_CLI":
return "No CLI";
case "NOT_LOGGED_IN":
return "Not logged in";
case "HTTP_ERROR":
if (error.httpStatus === 401) {
return "token no longer valid please /login again";
}
return `${error.httpStatus}`;
case "FETCH_FAILED":
case "API_ERROR":
case "TIMEOUT":
case "UNKNOWN":
default:
return "Fetch failed";
}
}
@@ -0,0 +1,55 @@
/**
* Shared path helpers for sub-core storage.
*/
import { getAgentDir } from "@mariozechner/pi-coding-agent";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SETTINGS_FILE_NAME = "pi-sub-core-settings.json";
const CACHE_DIR_NAME = "cache";
const CACHE_NAMESPACE_DIR = "sub-core";
const CACHE_FILE_NAME = "cache.json";
const CACHE_LOCK_FILE_NAME = "cache.lock";
const LEGACY_AGENT_CACHE_FILE_NAME = "pi-sub-core-cache.json";
const LEGACY_AGENT_LOCK_FILE_NAME = "pi-sub-core-cache.lock";
export function getExtensionDir(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..");
}
export function getCacheDir(): string {
return join(getAgentDir(), CACHE_DIR_NAME, CACHE_NAMESPACE_DIR);
}
export function getCachePath(): string {
return join(getCacheDir(), CACHE_FILE_NAME);
}
export function getCacheLockPath(): string {
return join(getCacheDir(), CACHE_LOCK_FILE_NAME);
}
export function getLegacyCachePath(): string {
return join(getExtensionDir(), "cache.json");
}
export function getLegacyCacheLockPath(): string {
return join(getExtensionDir(), "cache.lock");
}
export function getLegacyAgentCachePath(): string {
return join(getAgentDir(), LEGACY_AGENT_CACHE_FILE_NAME);
}
export function getLegacyAgentCacheLockPath(): string {
return join(getAgentDir(), LEGACY_AGENT_LOCK_FILE_NAME);
}
export function getSettingsPath(): string {
return join(getAgentDir(), SETTINGS_FILE_NAME);
}
export function getLegacySettingsPath(): string {
return join(getExtensionDir(), "settings.json");
}
@@ -0,0 +1,66 @@
/**
* Provider interface and registry
*/
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
/**
* Interface for a usage provider
*/
export interface UsageProvider {
readonly name: ProviderName;
readonly displayName: string;
/**
* Fetch current usage data for this provider
*/
fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
/**
* Fetch current status for this provider (optional)
*/
fetchStatus?(deps: Dependencies): Promise<ProviderStatus>;
/**
* Check if credentials are available (optional)
*/
hasCredentials?(deps: Dependencies): boolean;
}
/**
* Base class for providers with common functionality
*/
export abstract class BaseProvider implements UsageProvider {
abstract readonly name: ProviderName;
abstract readonly displayName: string;
abstract fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
hasCredentials(_deps: Dependencies): boolean {
return true;
}
/**
* Create an empty snapshot with an error
*/
protected emptySnapshot(error?: import("./types.js").UsageError): UsageSnapshot {
return {
provider: this.name,
displayName: this.displayName,
windows: [],
error,
};
}
/**
* Create a snapshot with usage data
*/
protected snapshot(data: Partial<Omit<UsageSnapshot, "provider" | "displayName">>): UsageSnapshot {
return {
provider: this.name,
displayName: this.displayName,
windows: [],
...data,
};
}
}
@@ -0,0 +1,51 @@
/**
* Provider detection helpers.
*/
import type { ProviderName } from "../types.js";
import { PROVIDERS } from "../types.js";
import { PROVIDER_METADATA } from "./metadata.js";
interface ProviderDetectionHint {
provider: ProviderName;
providerTokens: string[];
modelTokens: string[];
}
const PROVIDER_DETECTION_HINTS: ProviderDetectionHint[] = PROVIDERS.map((provider) => {
const detection = PROVIDER_METADATA[provider].detection ?? { providerTokens: [], modelTokens: [] };
return {
provider,
providerTokens: detection.providerTokens,
modelTokens: detection.modelTokens,
};
});
/**
* Detect the provider from model metadata.
*/
export function detectProviderFromModel(
model: { provider?: string; id?: string } | undefined
): ProviderName | undefined {
if (!model) return undefined;
const providerValue = model.provider?.toLowerCase() || "";
const idValue = model.id?.toLowerCase() || "";
if (providerValue.includes("antigravity") || idValue.includes("antigravity")) {
return "antigravity";
}
for (const hint of PROVIDER_DETECTION_HINTS) {
if (hint.providerTokens.some((token) => providerValue.includes(token))) {
return hint.provider;
}
}
for (const hint of PROVIDER_DETECTION_HINTS) {
if (hint.modelTokens.some((token) => idValue.includes(token))) {
return hint.provider;
}
}
return undefined;
}
@@ -0,0 +1,174 @@
/**
* Anthropic/Claude usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
import { getSettings } from "../../settings.js";
/**
* Load Claude API token from various sources
*/
function loadClaudeToken(deps: Dependencies): string | undefined {
// Explicit override via env var (useful in CI / menu bar apps)
const envToken = deps.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (envToken) return envToken;
// Try pi auth.json next
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data.anthropic?.access) return data.anthropic.access;
}
} catch {
// Ignore parse errors
}
// Try macOS Keychain (Claude Code credentials)
try {
const keychainData = deps.execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
).trim();
if (keychainData) {
const parsed = JSON.parse(keychainData);
const scopes = parsed.claudeAiOauth?.scopes || [];
if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) {
return parsed.claudeAiOauth.accessToken;
}
}
} catch {
// Keychain access failed
}
return undefined;
}
type ExtraUsageFormat = {
symbol: string;
decimalSeparator: "." | ",";
};
function getExtraUsageFormat(): ExtraUsageFormat {
const settings = getSettings();
const providerSettings = settings.providers.anthropic;
return {
symbol: providerSettings.extraUsageCurrencySymbol?.trim() ?? "",
decimalSeparator: providerSettings.extraUsageDecimalSeparator === "," ? "," : ".",
};
}
function formatExtraUsageCredits(credits: number, format: ExtraUsageFormat): string {
const amount = (credits / 100).toFixed(2);
const formatted = format.decimalSeparator === "," ? amount.replace(".", ",") : amount;
return format.symbol ? `${format.symbol}${formatted}` : formatted;
}
export class AnthropicProvider extends BaseProvider {
readonly name = "anthropic" as const;
readonly displayName = "Claude Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadClaudeToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadClaudeToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
five_hour?: { utilization?: number; resets_at?: string };
seven_day?: { utilization?: number; resets_at?: string };
extra_usage?: {
is_enabled?: boolean;
used_credits?: number;
monthly_limit?: number;
utilization?: number;
};
};
const windows: RateWindow[] = [];
if (data.five_hour?.utilization !== undefined) {
const resetAt = data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : undefined;
windows.push({
label: "5h",
usedPercent: data.five_hour.utilization,
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
}
if (data.seven_day?.utilization !== undefined) {
const resetAt = data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : undefined;
windows.push({
label: "Week",
usedPercent: data.seven_day.utilization,
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
}
// Extra usage
const extraUsageEnabled = data.extra_usage?.is_enabled === true;
const fiveHourUsage = data.five_hour?.utilization ?? 0;
if (extraUsageEnabled) {
const extra = data.extra_usage!;
const usedCredits = extra.used_credits || 0;
const monthlyLimit = extra.monthly_limit;
const utilization = extra.utilization || 0;
const format = getExtraUsageFormat();
// "active" when 5h >= 99%, otherwise "on"
const extraStatus = fiveHourUsage >= 99 ? "active" : "on";
let label: string;
if (monthlyLimit && monthlyLimit > 0) {
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}/${formatExtraUsageCredits(monthlyLimit, format)}`;
} else {
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}`;
}
windows.push({
label,
usedPercent: utilization,
resetDescription: extraStatus === "active" ? "__ACTIVE__" : undefined,
});
}
return this.snapshot({
windows,
extraUsageEnabled,
fiveHourUsage,
});
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,226 @@
/**
* Google Antigravity usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { createTimeoutController, formatReset } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
const ANTIGRAVITY_ENDPOINTS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
] as const;
const ANTIGRAVITY_HEADERS = {
"User-Agent": "antigravity/1.11.5 darwin/arm64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
const ANTIGRAVITY_HIDDEN_MODELS = new Set(["tab_flash_lite_preview"]);
interface AntigravityAuth {
access?: string;
accessToken?: string;
token?: string;
key?: string;
projectId?: string;
project?: string;
}
interface CloudCodeQuotaResponse {
models?: Record<string, {
displayName?: string;
model?: string;
isInternal?: boolean;
quotaInfo?: {
remainingFraction?: number;
limit?: string;
resetTime?: string;
};
}>;
}
interface ParsedModelQuota {
name: string;
remainingFraction: number;
resetAt?: Date;
}
/**
* Load Antigravity access token from auth.json
*/
function loadAntigravityAuth(deps: Dependencies): AntigravityAuth | undefined {
// Explicit override via env var
const envProjectId = (deps.env.GOOGLE_ANTIGRAVITY_PROJECT_ID || deps.env.GOOGLE_ANTIGRAVITY_PROJECT)?.trim();
const envToken = (deps.env.GOOGLE_ANTIGRAVITY_OAUTH_TOKEN || deps.env.ANTIGRAVITY_OAUTH_TOKEN)?.trim();
if (envToken) {
return { token: envToken, projectId: envProjectId || undefined };
}
// Also support passing pi-ai style JSON api key: { token, projectId }
const envApiKey = (deps.env.GOOGLE_ANTIGRAVITY_API_KEY || deps.env.ANTIGRAVITY_API_KEY)?.trim();
if (envApiKey) {
try {
const parsed = JSON.parse(envApiKey) as { token?: string; projectId?: string };
if (parsed?.token) {
return { token: parsed.token, projectId: parsed.projectId || envProjectId || undefined };
}
} catch {
// not JSON
}
return { token: envApiKey, projectId: envProjectId || undefined };
}
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
const entry = data["google-antigravity"];
if (!entry) return undefined;
if (typeof entry === "string") {
return { token: entry };
}
return {
access: entry.access,
accessToken: entry.accessToken,
token: entry.token,
key: entry.key,
projectId: entry.projectId ?? entry.project,
};
}
} catch {
// Ignore parse errors
}
return undefined;
}
function resolveAntigravityToken(auth: AntigravityAuth | undefined): string | undefined {
return auth?.access ?? auth?.accessToken ?? auth?.token ?? auth?.key;
}
function parseResetTime(value?: string): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date;
}
function toUsedPercent(remainingFraction: number): number {
const fraction = Number.isFinite(remainingFraction) ? remainingFraction : 1;
const used = (1 - fraction) * 100;
return Math.max(0, Math.min(100, used));
}
async function fetchAntigravityQuota(
deps: Dependencies,
endpoint: string,
token: string,
projectId?: string
): Promise<{ data?: CloudCodeQuotaResponse; status?: number }> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const payload = projectId ? { project: projectId } : {};
const res = await deps.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...ANTIGRAVITY_HEADERS,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clear();
if (!res.ok) return { status: res.status };
const data = (await res.json()) as CloudCodeQuotaResponse;
return { data };
} catch {
clear();
return {};
}
}
export class AntigravityProvider extends BaseProvider {
readonly name = "antigravity" as const;
readonly displayName = "Antigravity";
hasCredentials(deps: Dependencies): boolean {
return Boolean(resolveAntigravityToken(loadAntigravityAuth(deps)));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const auth = loadAntigravityAuth(deps);
const token = resolveAntigravityToken(auth);
if (!token) {
return this.emptySnapshot(noCredentials());
}
let data: CloudCodeQuotaResponse | undefined;
let lastStatus: number | undefined;
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
const result = await fetchAntigravityQuota(deps, endpoint, token, auth?.projectId);
if (result.data) {
data = result.data;
break;
}
if (result.status) {
lastStatus = result.status;
}
}
if (!data) {
return lastStatus ? this.emptySnapshot(httpError(lastStatus)) : this.emptySnapshot(fetchFailed());
}
const modelByName = new Map<string, ParsedModelQuota>();
for (const [modelId, model] of Object.entries(data.models ?? {})) {
if (model.isInternal) continue;
if (modelId && ANTIGRAVITY_HIDDEN_MODELS.has(modelId.toLowerCase())) continue;
const name = model.displayName ?? modelId ?? model.model ?? "unknown";
if (!name) continue;
if (ANTIGRAVITY_HIDDEN_MODELS.has(name.toLowerCase())) continue;
const remainingFraction = model.quotaInfo?.remainingFraction ?? 1;
const resetAt = parseResetTime(model.quotaInfo?.resetTime);
const existing = modelByName.get(name);
if (!existing) {
modelByName.set(name, { name, remainingFraction, resetAt });
continue;
}
let next = existing;
if (remainingFraction < existing.remainingFraction) {
next = { name, remainingFraction, resetAt };
} else if (remainingFraction === existing.remainingFraction && resetAt) {
if (!existing.resetAt || resetAt.getTime() < existing.resetAt.getTime()) {
next = { ...existing, resetAt };
}
} else if (!existing.resetAt && resetAt) {
next = { ...existing, resetAt };
}
if (next !== existing) {
modelByName.set(name, next);
}
}
const parsedModels = Array.from(modelByName.values()).sort((a, b) => a.name.localeCompare(b.name));
const buildWindow = (label: string, remainingFraction: number, resetAt?: Date): RateWindow => ({
label,
usedPercent: toUsedPercent(remainingFraction),
resetDescription: resetAt ? formatReset(resetAt) : undefined,
resetAt: resetAt?.toISOString(),
});
const windows = parsedModels.map((model) => buildWindow(model.name, model.remainingFraction, model.resetAt));
return this.snapshot({ windows });
}
}
@@ -0,0 +1,186 @@
/**
* OpenAI Codex usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
interface CodexRateWindow {
reset_at?: number;
limit_window_seconds?: number;
used_percent?: number;
}
interface CodexRateLimit {
primary_window?: CodexRateWindow;
secondary_window?: CodexRateWindow;
}
interface CodexAdditionalRateLimit {
limit_name?: string;
metered_feature?: string;
rate_limit?: CodexRateLimit;
}
/**
* Load Codex credentials from auth.json
* First tries pi's auth.json, then falls back to legacy codex location
*/
function loadCodexCredentials(deps: Dependencies): { accessToken?: string; accountId?: string } {
// Explicit override via env var
const envAccessToken = (
deps.env.OPENAI_CODEX_OAUTH_TOKEN ||
deps.env.OPENAI_CODEX_ACCESS_TOKEN ||
deps.env.CODEX_OAUTH_TOKEN ||
deps.env.CODEX_ACCESS_TOKEN
)?.trim();
const envAccountId = (deps.env.OPENAI_CODEX_ACCOUNT_ID || deps.env.CHATGPT_ACCOUNT_ID)?.trim();
if (envAccessToken) {
return { accessToken: envAccessToken, accountId: envAccountId || undefined };
}
// Try pi's auth.json first
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data["openai-codex"]?.access) {
return {
accessToken: data["openai-codex"].access,
accountId: data["openai-codex"].accountId,
};
}
}
} catch {
// Ignore parse errors, try legacy location
}
// Fall back to legacy codex location
const codexHome = deps.env.CODEX_HOME || path.join(deps.homedir(), ".codex");
const authPath = path.join(codexHome, "auth.json");
try {
if (deps.fileExists(authPath)) {
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
if (data.OPENAI_API_KEY) {
return { accessToken: data.OPENAI_API_KEY };
} else if (data.tokens?.access_token) {
return {
accessToken: data.tokens.access_token,
accountId: data.tokens.account_id,
};
}
}
} catch {
// Ignore parse errors
}
return {};
}
function getWindowLabel(windowSeconds?: number, fallbackWindowSeconds?: number): string {
const safeWindowSeconds =
typeof windowSeconds === "number" && windowSeconds > 0
? windowSeconds
: typeof fallbackWindowSeconds === "number" && fallbackWindowSeconds > 0
? fallbackWindowSeconds
: 0;
if (!safeWindowSeconds) {
return "0h";
}
const windowHours = Math.round(safeWindowSeconds / 3600);
if (windowHours >= 144) return "Week";
if (windowHours >= 24) return "Day";
return `${windowHours}h`;
}
function pushWindow(
windows: RateWindow[],
prefix: string | undefined,
window: CodexRateWindow | undefined,
fallbackWindowSeconds?: number
): void {
if (!window) return;
const resetDate = window.reset_at ? new Date(window.reset_at * 1000) : undefined;
const label = getWindowLabel(window.limit_window_seconds, fallbackWindowSeconds);
const windowLabel = prefix ? `${prefix} ${label}` : label;
windows.push({
label: windowLabel,
usedPercent: window.used_percent || 0,
resetDescription: resetDate ? formatReset(resetDate) : undefined,
resetAt: resetDate?.toISOString(),
});
}
function addRateWindows(windows: RateWindow[], rateLimit: CodexRateLimit | undefined, prefix?: string): void {
pushWindow(windows, prefix, rateLimit?.primary_window, 10800);
pushWindow(windows, prefix, rateLimit?.secondary_window, 86400);
}
export class CodexProvider extends BaseProvider {
readonly name = "codex" as const;
readonly displayName = "Codex Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadCodexCredentials(deps).accessToken);
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const { accessToken, accountId } = loadCodexCredentials(deps);
if (!accessToken) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
if (accountId) {
headers["ChatGPT-Account-Id"] = accountId;
}
const res = await deps.fetch("https://chatgpt.com/backend-api/wham/usage", {
headers,
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
rate_limit?: CodexRateLimit;
additional_rate_limits?: CodexAdditionalRateLimit[];
};
const windows: RateWindow[] = [];
addRateWindows(windows, data.rate_limit);
if (Array.isArray(data.additional_rate_limits)) {
for (const entry of data.additional_rate_limits) {
if (!entry || typeof entry !== "object") continue;
const prefix =
typeof entry.limit_name === "string" && entry.limit_name.trim().length > 0
? entry.limit_name.trim()
: typeof entry.metered_feature === "string" && entry.metered_feature.trim().length > 0
? entry.metered_feature.trim()
: "Additional";
addRateWindows(windows, entry.rate_limit, prefix);
}
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,176 @@
/**
* GitHub Copilot usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Copilot token entries stored by legacy GitHub Copilot CLI
*/
type CopilotHostEntry = {
oauth_token?: string;
user_token?: string;
github_token?: string;
token?: string;
};
const COPILOT_TOKEN_KEYS: Array<keyof CopilotHostEntry> = [
"oauth_token",
"user_token",
"github_token",
"token",
];
function getTokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined {
if (!entry) return undefined;
for (const key of COPILOT_TOKEN_KEYS) {
const value = entry[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return undefined;
}
function loadLegacyCopilotToken(deps: Dependencies): string | undefined {
const configHome = deps.env.XDG_CONFIG_HOME || path.join(deps.homedir(), ".config");
const legacyPaths = [
path.join(configHome, "github-copilot", "hosts.json"),
path.join(deps.homedir(), ".github-copilot", "hosts.json"),
];
for (const hostsPath of legacyPaths) {
try {
if (!deps.fileExists(hostsPath)) continue;
const data = JSON.parse(deps.readFile(hostsPath) ?? "{}");
if (!data || typeof data !== "object") continue;
const normalizedHosts: Record<string, CopilotHostEntry> = {};
for (const [host, entry] of Object.entries(data as Record<string, CopilotHostEntry>)) {
normalizedHosts[host.toLowerCase()] = entry;
}
const preferredToken =
getTokenFromHostEntry(normalizedHosts["github.com"]) ||
getTokenFromHostEntry(normalizedHosts["api.github.com"]);
if (preferredToken) return preferredToken;
for (const entry of Object.values(normalizedHosts)) {
const token = getTokenFromHostEntry(entry);
if (token) return token;
}
} catch {
// Ignore parse errors
}
}
return undefined;
}
/**
* Load Copilot token from pi auth.json first, then fallback to legacy locations.
*/
function loadCopilotToken(deps: Dependencies): string | undefined {
// Explicit override via env var
const envToken = (deps.env.COPILOT_GITHUB_TOKEN || deps.env.GH_TOKEN || deps.env.GITHUB_TOKEN || deps.env.COPILOT_TOKEN)?.trim();
if (envToken) return envToken;
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(authPath)) {
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
// Prefer refresh token (GitHub access token) for GitHub API endpoints.
const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access;
if (typeof piToken === "string" && piToken.length > 0) return piToken;
}
} catch {
// Ignore parse errors
}
return loadLegacyCopilotToken(deps);
}
export class CopilotProvider extends BaseProvider {
readonly name = "copilot" as const;
readonly displayName = "Copilot Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadCopilotToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadCopilotToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.github.com/copilot_internal/user", {
headers: {
"Editor-Version": "vscode/1.96.2",
"User-Agent": "GitHubCopilotChat/0.26.7",
"X-Github-Api-Version": "2025-04-01",
Accept: "application/json",
Authorization: `token ${token}`,
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
quota_reset_date_utc?: string;
quota_snapshots?: {
premium_interactions?: {
percent_remaining?: number;
remaining?: number;
entitlement?: number;
};
};
};
const windows: RateWindow[] = [];
const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
const resetDesc = resetDate ? formatReset(resetDate) : undefined;
let requestsRemaining: number | undefined;
let requestsEntitlement: number | undefined;
if (data.quota_snapshots?.premium_interactions) {
const pi = data.quota_snapshots.premium_interactions;
const monthUsedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
windows.push({
label: "Month",
usedPercent: monthUsedPercent,
resetDescription: resetDesc,
resetAt: resetDate?.toISOString(),
});
const remaining = pi.remaining ?? 0;
const entitlement = pi.entitlement ?? 0;
requestsRemaining = remaining;
requestsEntitlement = entitlement;
}
return this.snapshot({
windows,
requestsRemaining,
requestsEntitlement,
});
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,130 @@
/**
* Google Gemini usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
import { createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Load Gemini access token from various sources
*/
function loadGeminiToken(deps: Dependencies): string | undefined {
// Explicit override via env var
const envToken = (
deps.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
deps.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
deps.env.GEMINI_OAUTH_TOKEN ||
deps.env.GOOGLE_GEMINI_OAUTH_TOKEN
)?.trim();
if (envToken) return envToken;
// Try pi auth.json first
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(piAuthPath)) {
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
if (data["google-gemini-cli"]?.access) return data["google-gemini-cli"].access;
}
} catch {
// Ignore parse errors
}
// Try ~/.gemini/oauth_creds.json
const credPath = path.join(deps.homedir(), ".gemini", "oauth_creds.json");
try {
if (deps.fileExists(credPath)) {
const data = JSON.parse(deps.readFile(credPath) ?? "{}");
if (data.access_token) return data.access_token;
}
} catch {
// Ignore parse errors
}
return undefined;
}
export class GeminiProvider extends BaseProvider {
readonly name = "gemini" as const;
readonly displayName = "Gemini Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadGeminiToken(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const token = loadGeminiToken(deps);
if (!token) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: "{}",
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
buckets?: Array<{
modelId?: string;
remainingFraction?: number;
}>;
};
// Aggregate quotas by model type
const quotas: Record<string, number> = {};
for (const bucket of data.buckets || []) {
const model = bucket.modelId || "unknown";
const frac = bucket.remainingFraction ?? 1;
if (!quotas[model] || frac < quotas[model]) {
quotas[model] = frac;
}
}
const windows: RateWindow[] = [];
let proMin = 1;
let flashMin = 1;
let hasProModel = false;
let hasFlashModel = false;
for (const [model, frac] of Object.entries(quotas)) {
if (model.toLowerCase().includes("pro")) {
hasProModel = true;
if (frac < proMin) proMin = frac;
}
if (model.toLowerCase().includes("flash")) {
hasFlashModel = true;
if (frac < flashMin) flashMin = frac;
}
}
if (hasProModel) {
windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 });
}
if (hasFlashModel) {
windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 });
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
}
@@ -0,0 +1,92 @@
/**
* AWS Kiro usage provider
*/
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCli, notLoggedIn, fetchFailed } from "../../errors.js";
import { formatReset, stripAnsi, whichSync } from "../../utils.js";
import { CLI_TIMEOUT_MS } from "../../config.js";
export class KiroProvider extends BaseProvider {
readonly name = "kiro" as const;
readonly displayName = "Kiro Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(whichSync("kiro-cli", deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const kiroBinary = whichSync("kiro-cli", deps);
if (!kiroBinary) {
return this.emptySnapshot(noCli("kiro-cli"));
}
try {
// Check if logged in
try {
deps.execFileSync(kiroBinary, ["whoami"], {
encoding: "utf-8",
timeout: API_TIMEOUT_MS,
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
return this.emptySnapshot(notLoggedIn());
}
// Get usage
const output = deps.execFileSync(kiroBinary, ["chat", "--no-interactive", "/usage"], {
encoding: "utf-8",
timeout: CLI_TIMEOUT_MS,
env: { ...deps.env, TERM: "xterm-256color" },
stdio: ["ignore", "pipe", "pipe"],
});
const stripped = stripAnsi(output);
const windows: RateWindow[] = [];
// Parse credits percentage from "████...█ X%"
let creditsPercent = 0;
const percentMatch = stripped.match(/█+\s*(\d+)%/);
if (percentMatch) {
creditsPercent = parseInt(percentMatch[1], 10);
}
// Parse credits used/total from "(X.XX of Y covered in plan)"
const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/);
if (creditsMatch && !percentMatch) {
const creditsUsed = parseFloat(creditsMatch[1]);
const creditsTotal = parseFloat(creditsMatch[2]);
if (creditsTotal > 0) {
creditsPercent = (creditsUsed / creditsTotal) * 100;
}
}
// Parse reset date from "resets on 01/01"
let resetsAt: Date | undefined;
const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/);
if (resetMatch) {
const [month, day] = resetMatch[1].split("/").map(Number);
const now = new Date();
const year = now.getFullYear();
resetsAt = new Date(year, month - 1, day);
if (resetsAt < now) resetsAt.setFullYear(year + 1);
}
windows.push({
label: "Credits",
usedPercent: creditsPercent,
resetDescription: resetsAt ? formatReset(resetsAt) : undefined,
resetAt: resetsAt?.toISOString(),
});
return this.snapshot({ windows });
} catch {
return this.emptySnapshot(fetchFailed());
}
}
// Kiro doesn't have a public status page
}
const API_TIMEOUT_MS = 5000;
@@ -0,0 +1,191 @@
/**
* OpenCode Go usage provider
*
* OpenCode Go is a subscription plan ($10/mo) from the OpenCode team
* that provides access to open coding models (GLM-5, Kimi K2.5, MiniMax M2.5)
* through opencode.ai/zen/go/v1 endpoints.
*
* Credentials are discovered from:
* 1. Config file (~/.config/opencode/opencode-go-usage.json)
* 2. Environment variables (OPENCODE_GO_WORKSPACE_ID, OPENCODE_GO_AUTH_COOKIE)
* 3. Environment variable (OPENCODE_API_KEY) - for API key auth
*
* Usage limits: rolling 5h ($12), weekly ($30), monthly ($60) — tracked in dollar value.
*
* Usage is fetched by scraping the HTML dashboard at opencode.ai/workspace/{id}/go
* using the auth cookie from the browser.
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, apiError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
interface OpenCodeGoConfig {
workspaceId?: string;
authCookie?: string;
}
/**
* Load config from file or environment
*/
function loadOpenCodeGoConfig(deps: Dependencies): OpenCodeGoConfig | undefined {
// 1. Config file (~/.config/opencode/opencode-go-usage.json)
const configPath = path.join(deps.homedir(), ".config", "opencode", "opencode-go-usage.json");
try {
if (deps.fileExists(configPath)) {
const content = deps.readFile(configPath);
if (content) {
const parsed = JSON.parse(content) as OpenCodeGoConfig;
if (parsed.workspaceId && parsed.authCookie) {
return parsed;
}
}
}
} catch {
// Ignore parse errors
}
// 2. Environment variables
const workspaceId = deps.env.OPENCODE_GO_WORKSPACE_ID?.trim();
const authCookie = deps.env.OPENCODE_GO_AUTH_COOKIE?.trim();
if (workspaceId && authCookie) {
return { workspaceId, authCookie };
}
return undefined;
}
interface UsageData {
usagePercent: number;
resetInSec: number;
}
/**
* Fetch usage by scraping the HTML dashboard
*/
async function fetchOpenCodeGoUsage(
workspaceId: string,
authCookie: string,
fetch: typeof globalThis.fetch,
signal?: AbortSignal
): Promise<{ rolling?: UsageData; weekly?: UsageData; monthly?: UsageData }> {
const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Cookie: `auth=${authCookie}`,
},
signal,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error("Authentication failed. Please refresh your auth cookie.");
}
throw new Error(`HTTP ${response.status}: Request failed`);
}
const html = await response.text();
const usage: { rolling?: UsageData; weekly?: UsageData; monthly?: UsageData } = {};
const patterns = {
rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/,
weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/,
monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/,
};
for (const [key, pattern] of Object.entries(patterns)) {
const match = html.match(pattern);
if (match) {
try {
// Fix the JavaScript object syntax to valid JSON
const jsonStr = match[1].replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3');
usage[key as keyof typeof usage] = JSON.parse(jsonStr);
} catch {
// Ignore parse errors
}
}
}
return usage;
}
export class OpenCodeGoProvider extends BaseProvider {
readonly name = "opencode-go" as const;
readonly displayName = "OpenCode Go";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadOpenCodeGoConfig(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const config = loadOpenCodeGoConfig(deps);
if (!config || !config.workspaceId || !config.authCookie) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const usage = await fetchOpenCodeGoUsage(
config.workspaceId,
config.authCookie,
deps.fetch,
controller.signal
);
clear();
const windows: RateWindow[] = [];
if (usage.rolling) {
const resetDate = usage.rolling.resetInSec > 0
? new Date(Date.now() + usage.rolling.resetInSec * 1000)
: undefined;
windows.push({
label: "5h",
usedPercent: usage.rolling.usagePercent,
resetDescription: resetDate ? formatReset(resetDate) : undefined,
resetAt: resetDate?.toISOString(),
});
}
if (usage.weekly) {
const resetDate = usage.weekly.resetInSec > 0
? new Date(Date.now() + usage.weekly.resetInSec * 1000)
: undefined;
windows.push({
label: "Week",
usedPercent: usage.weekly.usagePercent,
resetDescription: resetDate ? formatReset(resetDate) : undefined,
resetAt: resetDate?.toISOString(),
});
}
if (usage.monthly) {
const resetDate = usage.monthly.resetInSec > 0
? new Date(Date.now() + usage.monthly.resetInSec * 1000)
: undefined;
windows.push({
label: "Month",
usedPercent: usage.monthly.usagePercent,
resetDescription: resetDate ? formatReset(resetDate) : undefined,
resetAt: resetDate?.toISOString(),
});
}
return this.snapshot({ windows });
} catch (err) {
clear();
const error = err instanceof Error ? err.message : "Unknown error";
if (error.includes("Authentication failed")) {
return this.emptySnapshot(apiError(error));
}
return this.emptySnapshot(fetchFailed(error));
}
}
}
@@ -0,0 +1,120 @@
/**
* z.ai usage provider
*/
import * as path from "node:path";
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
import { BaseProvider } from "../../provider.js";
import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js";
import { formatReset, createTimeoutController } from "../../utils.js";
import { API_TIMEOUT_MS } from "../../config.js";
/**
* Load z.ai API key from environment or auth.json
*/
function loadZaiApiKey(deps: Dependencies): string | undefined {
// Try environment variable first
if (deps.env.ZAI_API_KEY) {
return deps.env.ZAI_API_KEY;
}
if (deps.env.Z_AI_API_KEY) {
return deps.env.Z_AI_API_KEY;
}
// Try pi auth.json
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
try {
if (deps.fileExists(authPath)) {
const auth = JSON.parse(deps.readFile(authPath) ?? "{}");
return auth["z-ai"]?.access || auth["z-ai"]?.key || auth["zai"]?.access || auth["zai"]?.key;
}
} catch {
// Ignore parse errors
}
return undefined;
}
export class ZaiProvider extends BaseProvider {
readonly name = "zai" as const;
readonly displayName = "z.ai Plan";
hasCredentials(deps: Dependencies): boolean {
return Boolean(loadZaiApiKey(deps));
}
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
const apiKey = loadZaiApiKey(deps);
if (!apiKey) {
return this.emptySnapshot(noCredentials());
}
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
clear();
if (!res.ok) {
return this.emptySnapshot(httpError(res.status));
}
const data = (await res.json()) as {
success?: boolean;
code?: number;
msg?: string;
data?: {
limits?: Array<{
type?: string;
unit?: number;
number?: number;
percentage?: number;
nextResetTime?: string;
}>;
};
};
if (!data.success || data.code !== 200) {
return this.emptySnapshot(apiError(data.msg || "API error"));
}
const windows: RateWindow[] = [];
const limits = data.data?.limits || [];
for (const limit of limits) {
const percent = limit.percentage || 0;
const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined;
if (limit.type === "TOKENS_LIMIT") {
windows.push({
label: "Tokens",
usedPercent: percent,
resetDescription: nextReset ? formatReset(nextReset) : undefined,
resetAt: nextReset?.toISOString(),
});
} else if (limit.type === "TIME_LIMIT") {
windows.push({
label: "Monthly",
usedPercent: percent,
resetDescription: nextReset ? formatReset(nextReset) : undefined,
resetAt: nextReset?.toISOString(),
});
}
}
return this.snapshot({ windows });
} catch {
clear();
return this.emptySnapshot(fetchFailed());
}
}
// z.ai doesn't have a public status page
}
@@ -0,0 +1,5 @@
/**
* Provider registry exports.
*/
export * from "./registry.js";
@@ -0,0 +1,16 @@
/**
* Provider metadata shared across the core.
*/
export {
PROVIDERS,
PROVIDER_METADATA,
PROVIDER_DISPLAY_NAMES,
} from "../../../src/shared.js";
export type {
ProviderName,
ProviderMetadata,
ProviderStatusConfig,
ProviderDetectionConfig,
} from "../../../src/shared.js";
@@ -0,0 +1,57 @@
/**
* Provider registry - exports all providers
*/
export { AnthropicProvider } from "./impl/anthropic.js";
export { CopilotProvider } from "./impl/copilot.js";
export { GeminiProvider } from "./impl/gemini.js";
export { AntigravityProvider } from "./impl/antigravity.js";
export { CodexProvider } from "./impl/codex.js";
export { KiroProvider } from "./impl/kiro.js";
export { ZaiProvider } from "./impl/zai.js";
export { OpenCodeGoProvider } from "./impl/opencode-go.js";
import type { Dependencies, ProviderName } from "../types.js";
import type { UsageProvider } from "../provider.js";
import { PROVIDERS } from "./metadata.js";
import { AnthropicProvider } from "./impl/anthropic.js";
import { CopilotProvider } from "./impl/copilot.js";
import { GeminiProvider } from "./impl/gemini.js";
import { AntigravityProvider } from "./impl/antigravity.js";
import { CodexProvider } from "./impl/codex.js";
import { KiroProvider } from "./impl/kiro.js";
import { ZaiProvider } from "./impl/zai.js";
import { OpenCodeGoProvider } from "./impl/opencode-go.js";
const PROVIDER_FACTORIES: Record<ProviderName, () => UsageProvider> = {
anthropic: () => new AnthropicProvider(),
copilot: () => new CopilotProvider(),
gemini: () => new GeminiProvider(),
antigravity: () => new AntigravityProvider(),
codex: () => new CodexProvider(),
kiro: () => new KiroProvider(),
zai: () => new ZaiProvider(),
"opencode-go": () => new OpenCodeGoProvider(),
};
/**
* Create a provider instance by name
*/
export function createProvider(name: ProviderName): UsageProvider {
return PROVIDER_FACTORIES[name]();
}
/**
* Get all provider instances
*/
export function getAllProviders(): UsageProvider[] {
return PROVIDERS.map((name) => PROVIDER_FACTORIES[name]());
}
export function hasProviderCredentials(name: ProviderName, deps: Dependencies): boolean {
const provider = createProvider(name);
if (provider.hasCredentials) {
return provider.hasCredentials(deps);
}
return true;
}
@@ -0,0 +1,109 @@
/**
* Provider-specific settings helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { ProviderName } from "../types.js";
import type { Settings, CoreProviderSettings } from "../settings-types.js";
import { CUSTOM_OPTION } from "../ui/settings-list.js";
function buildBaseProviderItems(ps: CoreProviderSettings): SettingItem[] {
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
return [
{
id: "enabled",
label: "Enabled",
currentValue: enabledValue,
values: ["auto", "on", "off"],
description: "Auto enables if credentials are detected.",
},
{
id: "fetchStatus",
label: "Fetch Status",
currentValue: ps.fetchStatus ? "on" : "off",
values: ["on", "off"],
description: "Fetch status page indicator for this provider.",
},
];
}
function resolveEnabledValue(value: string): CoreProviderSettings["enabled"] {
if (value === "auto") return "auto";
return value === "on";
}
function applyBaseProviderSetting(ps: CoreProviderSettings, id: string, value: string): boolean {
switch (id) {
case "enabled":
ps.enabled = resolveEnabledValue(value);
return true;
case "fetchStatus":
ps.fetchStatus = value === "on";
return true;
default:
return false;
}
}
/**
* Build settings items for a specific provider.
*/
export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] {
const ps = settings.providers[provider];
const items = buildBaseProviderItems(ps);
if (provider === "anthropic") {
const currencySymbol = ps.extraUsageCurrencySymbol?.trim();
items.push(
{
id: "extraUsageCurrencySymbol",
label: "Extra Usage Currency Symbol",
currentValue: currencySymbol ? currencySymbol : "none",
values: ["none", CUSTOM_OPTION],
description: "Prefix symbol for Extra usage amounts.",
},
{
id: "extraUsageDecimalSeparator",
label: "Extra Usage Decimal Separator",
currentValue: ps.extraUsageDecimalSeparator === "," ? "," : ".",
values: [".", ","],
description: "Decimal separator for Extra usage amounts.",
},
);
}
return items;
}
/**
* Apply a provider settings change in-place.
*/
export function applyProviderSettingsChange(
settings: Settings,
provider: ProviderName,
id: string,
value: string
): Settings {
const ps = settings.providers[provider];
if (applyBaseProviderSetting(ps, id, value)) {
return settings;
}
switch (id) {
case "extraUsageCurrencySymbol":
if (value === CUSTOM_OPTION) {
return settings;
}
if (value === "none") {
delete ps.extraUsageCurrencySymbol;
return settings;
}
ps.extraUsageCurrencySymbol = value;
return settings;
case "extraUsageDecimalSeparator":
ps.extraUsageDecimalSeparator = value === "," ? "," : ".";
return settings;
default:
return settings;
}
}
@@ -0,0 +1,25 @@
/**
* Provider status handling helpers.
*/
import type { Dependencies, ProviderName, ProviderStatus } from "../types.js";
import { fetchProviderStatus } from "../status.js";
import { PROVIDER_METADATA } from "./metadata.js";
export function providerHasStatus(
provider: ProviderName,
providerInstance?: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> }
): boolean {
return Boolean(providerInstance?.fetchStatus) || Boolean(PROVIDER_METADATA[provider]?.status);
}
export async function fetchProviderStatusWithFallback(
provider: ProviderName,
providerInstance: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> },
deps: Dependencies
): Promise<ProviderStatus> {
if (providerInstance.fetchStatus) {
return providerInstance.fetchStatus(deps);
}
return fetchProviderStatus(provider, deps);
}
@@ -0,0 +1,95 @@
/**
* Settings types and defaults for sub-core
*/
import type {
CoreSettings,
CoreProviderSettingsMap,
CoreProviderSettings,
BehaviorSettings,
ProviderName,
ProviderEnabledSetting,
} from "../../src/shared.js";
import { PROVIDERS, getDefaultCoreSettings } from "../../src/shared.js";
export type {
CoreProviderSettings,
CoreProviderSettingsMap,
BehaviorSettings,
CoreSettings,
ProviderEnabledSetting,
} from "../../src/shared.js";
/**
* Tool registration settings
*/
export interface ToolSettings {
usageTool: boolean;
allUsageTool: boolean;
}
/**
* All settings
*/
export interface Settings extends CoreSettings {
/** Version for migration */
version: number;
/** Tool registration settings */
tools: ToolSettings;
}
/**
* Current settings version
*/
export const SETTINGS_VERSION = 3;
/**
* Default settings
*/
export function getDefaultSettings(): Settings {
const coreDefaults = getDefaultCoreSettings();
return {
version: SETTINGS_VERSION,
tools: {
usageTool: false,
allUsageTool: false,
},
providers: coreDefaults.providers,
behavior: coreDefaults.behavior,
statusRefresh: coreDefaults.statusRefresh,
providerOrder: coreDefaults.providerOrder,
defaultProvider: coreDefaults.defaultProvider,
};
}
/**
* Deep merge two objects
*/
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
const result = { ...target } as T;
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = result[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue) &&
typeof targetValue === "object" &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T];
}
}
return result;
}
/**
* Merge settings with defaults (no legacy migrations).
*/
export function mergeSettings(loaded: Partial<Settings>): Settings {
return deepMerge(getDefaultSettings(), loaded);
}
@@ -0,0 +1 @@
export { showSettingsUI } from "./settings/ui.js";
@@ -0,0 +1,137 @@
/**
* Settings persistence for sub-core
*/
import * as path from "node:path";
import type { Settings } from "./settings-types.js";
import { getDefaultSettings, mergeSettings, SETTINGS_VERSION } from "./settings-types.js";
import { getStorage } from "./storage.js";
import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
import { clearCache } from "./cache.js";
/**
* Settings file path
*/
export const SETTINGS_PATH = getSettingsPath();
const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
/**
* In-memory settings cache
*/
let cachedSettings: Settings | undefined;
type LoadedSettings = {
settings: Settings;
loadedVersion: number;
};
/**
* Ensure the settings directory exists
*/
function ensureSettingsDir(): void {
const storage = getStorage();
const dir = path.dirname(SETTINGS_PATH);
storage.ensureDir(dir);
}
function loadSettingsFromDisk(settingsPath: string): LoadedSettings | null {
const storage = getStorage();
if (!storage.exists(settingsPath)) return null;
const content = storage.readFile(settingsPath);
if (!content) return null;
const loaded = JSON.parse(content) as Partial<Settings>;
const loadedVersion = typeof loaded.version === "number" ? loaded.version : 0;
const merged = mergeSettings(loaded);
return { settings: merged, loadedVersion };
}
function applyVersionMigration(settings: Settings, loadedVersion: number): { settings: Settings; needsSave: boolean } {
if (loadedVersion < SETTINGS_VERSION) {
clearCache();
return { settings: { ...settings, version: SETTINGS_VERSION }, needsSave: true };
}
return { settings, needsSave: false };
}
function tryLoadSettings(settingsPath: string): LoadedSettings | null {
try {
return loadSettingsFromDisk(settingsPath);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return null;
}
}
/**
* Load settings from disk
*/
export function loadSettings(): Settings {
if (cachedSettings) {
return cachedSettings;
}
const diskSettings = tryLoadSettings(SETTINGS_PATH);
if (diskSettings) {
const { settings: next, needsSave } = applyVersionMigration(diskSettings.settings, diskSettings.loadedVersion);
if (needsSave) {
saveSettings(next);
}
cachedSettings = next;
return cachedSettings;
}
const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
if (legacySettings) {
const { settings: next } = applyVersionMigration(legacySettings.settings, legacySettings.loadedVersion);
const saved = saveSettings(next);
if (saved) {
getStorage().removeFile(LEGACY_SETTINGS_PATH);
}
cachedSettings = next;
return cachedSettings;
}
// Return defaults if file doesn't exist or failed to load
cachedSettings = getDefaultSettings();
return cachedSettings;
}
/**
* Save settings to disk
*/
export function saveSettings(settings: Settings): boolean {
const storage = getStorage();
try {
ensureSettingsDir();
const content = JSON.stringify(settings, null, 2);
storage.writeFile(SETTINGS_PATH, content);
cachedSettings = settings;
return true;
} catch (error) {
console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
return false;
}
}
/**
* Reset settings to defaults
*/
export function resetSettings(): Settings {
const defaults = getDefaultSettings();
saveSettings(defaults);
return defaults;
}
/**
* Get current settings (cached)
*/
export function getSettings(): Settings {
return loadSettings();
}
/**
* Clear the settings cache (force reload on next access)
*/
export function clearSettingsCache(): void {
cachedSettings = undefined;
}
@@ -0,0 +1,58 @@
/**
* Behavior settings UI helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { BehaviorSettings } from "../settings-types.js";
import { CUSTOM_OPTION } from "../ui/settings-list.js";
export function buildRefreshItems(settings: BehaviorSettings): SettingItem[] {
return [
{
id: "refreshInterval",
label: "Auto-refresh Interval",
currentValue: settings.refreshInterval === 0 ? "off" : `${settings.refreshInterval}s`,
values: ["off", "15s", "30s", "60s", "120s", "300s", CUSTOM_OPTION],
description: "How often to refresh automatically.",
},
{
id: "minRefreshInterval",
label: "Minimum Refresh Interval",
currentValue: settings.minRefreshInterval === 0 ? "off" : `${settings.minRefreshInterval}s`,
values: ["off", "5s", "10s", "15s", "30s", "60s", "120s", CUSTOM_OPTION],
description: "Cap refreshes even when triggered each turn.",
},
{
id: "refreshOnTurnStart",
label: "Refresh on Turn Start",
currentValue: settings.refreshOnTurnStart ? "on" : "off",
values: ["on", "off"],
description: "Refresh when a new turn starts.",
},
{
id: "refreshOnToolResult",
label: "Refresh on Tool Result",
currentValue: settings.refreshOnToolResult ? "on" : "off",
values: ["on", "off"],
description: "Refresh after tool executions.",
},
];
}
export function applyRefreshChange(settings: BehaviorSettings, id: string, value: string): BehaviorSettings {
switch (id) {
case "refreshInterval":
settings.refreshInterval = value === "off" ? 0 : parseInt(value, 10);
break;
case "minRefreshInterval":
settings.minRefreshInterval = value === "off" ? 0 : parseInt(value, 10);
break;
case "refreshOnTurnStart":
settings.refreshOnTurnStart = value === "on";
break;
case "refreshOnToolResult":
settings.refreshOnToolResult = value === "on";
break;
}
return settings;
}
@@ -0,0 +1,83 @@
/**
* Settings menu item builders.
*/
import type { SelectItem } from "@mariozechner/pi-tui";
import type { Settings } from "../settings-types.js";
import type { ProviderName } from "../types.js";
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
export type TooltipSelectItem = SelectItem & { tooltip?: string };
export function buildMainMenuItems(settings: Settings): TooltipSelectItem[] {
const enabledCount = Object.values(settings.providers).filter((p) => p.enabled !== "off" && p.enabled !== false).length;
const totalCount = Object.keys(settings.providers).length;
const toolEnabledCount = [settings.tools.usageTool, settings.tools.allUsageTool].filter(Boolean).length;
const toolTotalCount = 2;
return [
{
value: "providers",
label: "Provider Settings",
description: `${enabledCount}/${totalCount} enabled`,
tooltip: "Enable providers, toggle status fetch, and adjust provider settings.",
},
{
value: "behavior",
label: "Usage Refresh Settings",
description: `refresh ${settings.behavior.refreshInterval}s`,
tooltip: "Control usage refresh interval and triggers.",
},
{
value: "status-refresh",
label: "Status Refresh Settings",
description: `refresh ${settings.statusRefresh.refreshInterval}s`,
tooltip: "Control status refresh interval and triggers.",
},
{
value: "tools",
label: "Tool Settings",
description: `${toolEnabledCount}/${toolTotalCount} enabled`,
tooltip: "Enable sub-core tools (requires /reload to take effect).",
},
{
value: "provider-order",
label: "Provider Order",
description: settings.providerOrder.slice(0, 3).join(", ") + "...",
tooltip: "Reorder providers for cycling and auto-selection.",
},
{
value: "reset",
label: "Reset to Defaults",
description: "restore all settings",
tooltip: "Restore all sub-core settings to defaults.",
},
];
}
export function buildProviderListItems(settings: Settings): TooltipSelectItem[] {
return settings.providerOrder.map((provider) => {
const ps = settings.providers[provider];
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
const statusIcon = ps.fetchStatus ? ", status fetch on" : "";
return {
value: `provider-${provider}`,
label: PROVIDER_DISPLAY_NAMES[provider],
description: `enabled ${enabledValue}${statusIcon}`,
tooltip: `Enable ${PROVIDER_DISPLAY_NAMES[provider]} and configure status fetching.`,
};
});
}
export function buildProviderOrderItems(settings: Settings): TooltipSelectItem[] {
const activeProviders = settings.providerOrder.filter((provider) => {
const enabled = settings.providers[provider].enabled;
return enabled !== "off" && enabled !== false;
});
return activeProviders.map((provider, index) => ({
value: provider,
label: `${index + 1}. ${PROVIDER_DISPLAY_NAMES[provider]}`,
tooltip: "Reorder enabled providers (Space to toggle move mode).",
}));
}
@@ -0,0 +1,38 @@
/**
* Tool settings UI helpers.
*/
import type { SettingItem } from "@mariozechner/pi-tui";
import type { Settings, ToolSettings } from "../settings-types.js";
export function buildToolItems(settings: ToolSettings): SettingItem[] {
return [
{
id: "usageTool",
label: "Usage Tool",
currentValue: settings.usageTool ? "on" : "off",
values: ["on", "off"],
description: "Expose sub_get_usage/get_current_usage (requires /reload).",
},
{
id: "allUsageTool",
label: "All Usage Tool",
currentValue: settings.allUsageTool ? "on" : "off",
values: ["on", "off"],
description: "Expose sub_get_all_usage/get_all_usage (requires /reload).",
},
];
}
export function applyToolChange(settings: Settings, id: string, value: string): Settings {
const enabled = value === "on";
switch (id) {
case "usageTool":
settings.tools.usageTool = enabled;
break;
case "allUsageTool":
settings.tools.allUsageTool = enabled;
break;
}
return settings;
}
@@ -0,0 +1,450 @@
/**
* Settings UI for sub-core
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, Input, type SelectItem, SelectList, Spacer, Text } from "@mariozechner/pi-tui";
import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js";
import type { ProviderName } from "../types.js";
import type { Settings } from "../settings-types.js";
import { getDefaultSettings } from "../settings-types.js";
import { getSettings, saveSettings, resetSettings } from "../settings.js";
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js";
import { buildRefreshItems, applyRefreshChange } from "./behavior.js";
import { buildToolItems, applyToolChange } from "./tools.js";
import { buildMainMenuItems, buildProviderListItems, buildProviderOrderItems, type TooltipSelectItem } from "./menu.js";
/**
* Settings category
*/
type ProviderCategory = `provider-${ProviderName}`;
type SettingsCategory =
| "main"
| "providers"
| ProviderCategory
| "behavior"
| "status-refresh"
| "tools"
| "provider-order";
/**
* Extract provider name from category
*/
function getProviderFromCategory(category: SettingsCategory): ProviderName | null {
const match = category.match(/^provider-(\w+)$/);
if (match && match[1] !== "order") {
return match[1] as ProviderName;
}
return null;
}
/**
* Show the settings UI
*/
export async function showSettingsUI(
ctx: ExtensionContext,
onSettingsChange?: (settings: Settings) => void | Promise<void>
): Promise<Settings> {
let settings = getSettings();
let currentCategory: SettingsCategory = "main";
let providerOrderSelectedIndex = 0;
let providerOrderReordering = false;
let suppressProviderOrderChange = false;
return new Promise((resolve) => {
ctx.ui.custom<Settings>((tui, theme, _kb, done) => {
let container = new Container();
let activeList: SelectList | SettingsList | null = null;
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
const buildInputSubmenu = (
label: string,
parseValue: (value: string) => string | null,
formatInitial?: (value: string) => string,
) => {
return (currentValue: string, done: (selectedValue?: string) => void) => {
const input = new Input();
input.focused = true;
input.setValue(formatInitial ? formatInitial("") : "");
input.onSubmit = (value) => {
const parsed = parseValue(value);
if (!parsed) return;
done(parsed);
};
input.onEscape = () => {
done();
};
const inputContainer = new Container();
inputContainer.addChild(new Text(theme.fg("muted", label), 1, 0));
inputContainer.addChild(new Spacer(1));
inputContainer.addChild(input);
return {
render: (width: number) => inputContainer.render(width),
invalidate: () => inputContainer.invalidate(),
handleInput: (data: string) => input.handleInput(data),
};
};
};
const parseRefreshInterval = (raw: string): string | null => {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
ctx.ui.notify("Enter a value", "warning");
return null;
}
if (trimmed === "off") return "off";
const cleaned = trimmed.replace(/s$/, "");
const parsed = Number.parseInt(cleaned, 10);
if (Number.isNaN(parsed)) {
ctx.ui.notify("Enter seconds", "warning");
return null;
}
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
return clamped === 0 ? "off" : `${clamped}s`;
};
const parseMinRefreshInterval = (raw: string): string | null => {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
ctx.ui.notify("Enter a value", "warning");
return null;
}
if (trimmed === "off") return "off";
const cleaned = trimmed.replace(/s$/, "");
const parsed = Number.parseInt(cleaned, 10);
if (Number.isNaN(parsed)) {
ctx.ui.notify("Enter seconds", "warning");
return null;
}
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
return clamped === 0 ? "off" : `${clamped}s`;
};
const parseCurrencySymbol = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) {
ctx.ui.notify("Enter a symbol or 'none'", "warning");
return null;
}
if (trimmed.toLowerCase() === "none") return "none";
return trimmed;
};
function rebuild(): void {
container = new Container();
let tooltipText: Text | null = null;
const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => {
if (!items.some((item) => item.tooltip)) return;
const tooltipComponent = new Text("", 1, 0);
const setTooltip = (item?: TooltipSelectItem | null) => {
const tooltip = item?.tooltip?.trim();
tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : "");
};
setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null);
const existingHandler = selectList.onSelectionChange;
selectList.onSelectionChange = (item) => {
if (existingHandler) existingHandler(item);
setTooltip(item as TooltipSelectItem);
tui.requestRender();
};
tooltipText = tooltipComponent;
};
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
const titles: Record<string, string> = {
main: "sub-core Settings",
providers: "Provider Settings",
behavior: "Usage Refresh Settings",
"status-refresh": "Status Refresh Settings",
tools: "Tool Settings",
"provider-order": "Provider Order",
};
const providerCategory = getProviderFromCategory(currentCategory);
const title = providerCategory
? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings`
: titles[currentCategory] ?? "sub-core Settings";
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
container.addChild(new Spacer(1));
if (currentCategory === "main") {
const items = buildMainMenuItems(settings);
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => theme.fg("accent", t),
selectedText: (t: string) => theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
attachTooltip(items, selectList);
selectList.onSelect = (item) => {
if (item.value === "reset") {
settings = resetSettings();
if (onSettingsChange) void onSettingsChange(settings);
ctx.ui.notify("Settings reset to defaults", "info");
rebuild();
tui.requestRender();
} else {
currentCategory = item.value as SettingsCategory;
rebuild();
tui.requestRender();
}
};
selectList.onCancel = () => {
saveSettings(settings);
done(settings);
};
activeList = selectList;
container.addChild(selectList);
} else if (currentCategory === "providers") {
const items = buildProviderListItems(settings);
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => theme.fg("accent", t),
selectedText: (t: string) => theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
attachTooltip(items, selectList);
selectList.onSelect = (item) => {
currentCategory = item.value as SettingsCategory;
rebuild();
tui.requestRender();
};
selectList.onCancel = () => {
currentCategory = "main";
rebuild();
tui.requestRender();
};
activeList = selectList;
container.addChild(selectList);
} else if (currentCategory === "provider-order") {
const items = buildProviderOrderItems(settings);
const isReordering = providerOrderReordering;
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
selectedText: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
description: (t: string) => theme.fg("muted", t),
scrollInfo: (t: string) => theme.fg("dim", t),
noMatch: (t: string) => theme.fg("warning", t),
});
if (items.length > 0) {
suppressProviderOrderChange = true;
providerOrderSelectedIndex = Math.min(providerOrderSelectedIndex, items.length - 1);
selectList.setSelectedIndex(providerOrderSelectedIndex);
suppressProviderOrderChange = false;
}
selectList.onSelectionChange = (item) => {
if (suppressProviderOrderChange) return;
const newIndex = items.findIndex((listItem) => listItem.value === item.value);
if (newIndex === -1) return;
if (!providerOrderReordering) {
providerOrderSelectedIndex = newIndex;
return;
}
const activeProviders = settings.providerOrder.filter((provider) => {
const enabled = settings.providers[provider].enabled;
return enabled !== "off" && enabled !== false;
});
const oldIndex = providerOrderSelectedIndex;
if (newIndex === oldIndex) return;
if (oldIndex < 0 || oldIndex >= activeProviders.length) return;
const provider = activeProviders[oldIndex];
const updatedActive = [...activeProviders];
updatedActive.splice(oldIndex, 1);
updatedActive.splice(newIndex, 0, provider);
let activeIndex = 0;
settings.providerOrder = settings.providerOrder.map((existing) => {
const enabled = settings.providers[existing].enabled;
if (enabled === "off" || enabled === false) return existing;
const next = updatedActive[activeIndex];
activeIndex += 1;
return next;
});
providerOrderSelectedIndex = newIndex;
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
rebuild();
tui.requestRender();
};
attachTooltip(items, selectList);
selectList.onSelect = () => {
if (items.length === 0) return;
providerOrderReordering = !providerOrderReordering;
rebuild();
tui.requestRender();
};
selectList.onCancel = () => {
if (providerOrderReordering) {
providerOrderReordering = false;
rebuild();
tui.requestRender();
return;
}
currentCategory = "main";
rebuild();
tui.requestRender();
};
activeList = selectList;
container.addChild(selectList);
} else {
let items: SettingItem[];
let handleChange: (id: string, value: string) => void;
let backCategory: SettingsCategory = "main";
const provider = getProviderFromCategory(currentCategory);
if (provider) {
items = buildProviderSettingsItems(settings, provider);
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {};
if (provider === "anthropic") {
customHandlers.extraUsageCurrencySymbol = buildInputSubmenu(
"Extra Usage Currency Symbol",
parseCurrencySymbol,
undefined,
);
}
for (const item of items) {
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
item.submenu = customHandlers[item.id];
}
}
handleChange = (id, value) => {
settings = applyProviderSettingsChange(settings, provider, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "providers";
} else if (currentCategory === "tools") {
items = buildToolItems(settings.tools);
handleChange = (id, value) => {
settings = applyToolChange(settings, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "main";
} else {
const refreshTarget = currentCategory === "status-refresh" ? settings.statusRefresh : settings.behavior;
items = buildRefreshItems(refreshTarget);
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {
refreshInterval: buildInputSubmenu("Auto-refresh Interval (seconds)", parseRefreshInterval),
minRefreshInterval: buildInputSubmenu("Minimum Refresh Interval (seconds)", parseMinRefreshInterval),
};
for (const item of items) {
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
item.submenu = customHandlers[item.id];
}
}
handleChange = (id, value) => {
applyRefreshChange(refreshTarget, id, value);
saveSettings(settings);
if (onSettingsChange) void onSettingsChange(settings);
};
backCategory = "main";
}
const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel";
const customTheme = {
...getSettingsListTheme(),
hint: (text: string) => {
if (text.includes("Enter/Space")) {
return theme.fg("dim", settingsHintText);
}
return theme.fg("dim", text);
},
};
const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
customTheme,
handleChange,
() => {
currentCategory = backCategory;
rebuild();
tui.requestRender();
}
);
activeList = settingsList;
container.addChild(settingsList);
}
const usesSettingsList =
currentCategory === "behavior" ||
currentCategory === "status-refresh" ||
currentCategory === "tools" ||
getProviderFromCategory(currentCategory) !== null;
if (!usesSettingsList) {
let helpText: string;
if (currentCategory === "main" || currentCategory === "providers") {
helpText = "↑↓ navigate • Enter/Space select • Esc back";
} else if (currentCategory === "provider-order") {
helpText = providerOrderReordering
? "↑↓ move provider • Esc back"
: "↑↓ navigate • Enter/Space select • Esc back";
} else {
helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel";
}
if (tooltipText) {
container.addChild(new Spacer(1));
container.addChild(tooltipText);
}
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", helpText), 1, 0));
}
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
}
rebuild();
return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
if (data === " ") {
if (currentCategory === "provider-order") {
providerOrderReordering = !providerOrderReordering;
rebuild();
tui.requestRender();
return;
}
if (activeList && "handleInput" in activeList && activeList.handleInput) {
activeList.handleInput("\r");
}
tui.requestRender();
return;
}
if (activeList && "handleInput" in activeList && activeList.handleInput) {
activeList.handleInput(data);
}
tui.requestRender();
},
};
}).then(resolve);
});
}
@@ -0,0 +1,245 @@
/**
* Status polling for providers
*/
import type { Dependencies, ProviderName, ProviderStatus, StatusIndicator } from "./types.js";
import type { ProviderStatusConfig } from "./providers/metadata.js";
import { GOOGLE_STATUS_URL, GEMINI_PRODUCT_ID, API_TIMEOUT_MS } from "./config.js";
import { PROVIDER_METADATA } from "./providers/metadata.js";
import { createTimeoutController } from "./utils.js";
type StatusPageStatusConfig = Extract<ProviderStatusConfig, { type: "statuspage" }>;
interface StatusPageSummary {
status?: {
indicator?: string;
description?: string;
};
components?: Array<{
id?: string;
name?: string;
status?: string;
}>;
}
interface StatusPageStatus {
indicator?: string;
description?: string;
}
interface StatusPageResponse {
status?: StatusPageStatus;
}
function toSummaryUrl(url: string): string {
if (url.endsWith("/summary.json")) return url;
if (url.endsWith("/status.json")) {
return `${url.slice(0, -"/status.json".length)}/summary.json`;
}
if (!url.endsWith("/")) return `${url}/summary.json`;
return `${url}summary.json`;
}
function isComponentMatch(component: { id?: string; name?: string }, config?: StatusPageStatusConfig): boolean {
if (!config?.component) return false;
if (config.component.id && component.id) {
return component.id === config.component.id;
}
if (config.component.name && component.name) {
return component.name.trim().toLowerCase() === config.component.name.trim().toLowerCase();
}
return false;
}
function mapStatusIndicator(indicator?: string): StatusIndicator {
switch (indicator) {
case "none":
return "none";
case "minor":
return "minor";
case "major":
return "major";
case "critical":
return "critical";
case "maintenance":
return "maintenance";
default:
return "unknown";
}
}
function mapComponentStatus(indicator?: string): StatusIndicator {
switch ((indicator || "").toLowerCase()) {
case "operational":
return "none";
case "under_maintenance":
return "maintenance";
case "degraded_performance":
return "minor";
case "partial_outage":
return "major";
case "major_outage":
return "critical";
default:
return "unknown";
}
}
function formatComponentLabel(rawStatus?: string): string {
switch ((rawStatus || "").toLowerCase()) {
case "operational":
return "Operational";
case "under_maintenance":
return "Under maintenance";
case "degraded_performance":
return "Degraded performance";
case "partial_outage":
return "Partial outage";
case "major_outage":
return "Major outage";
default:
return rawStatus ? rawStatus.replace(/_/g, " ") : "Unknown";
}
}
/**
* Fetch status from a standard statuspage.io API
*/
async function fetchStatuspageStatus(
url: string,
deps: Dependencies,
config?: StatusPageStatusConfig
): Promise<ProviderStatus> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const fetchUrl = config?.component ? toSummaryUrl(url) : url;
const res = await deps.fetch(fetchUrl, { signal: controller.signal });
clear();
if (!res.ok) {
return { indicator: "unknown" };
}
if (!config?.component) {
const data = (await res.json()) as StatusPageResponse;
const indicator = mapStatusIndicator(data.status?.indicator);
return { indicator, description: data.status?.description };
}
const data = (await res.json()) as StatusPageSummary;
const summaryIndicator = mapStatusIndicator(data.status?.indicator);
const component = (data.components ?? []).find((entry) => isComponentMatch(entry, config));
if (component) {
const componentIndicator = mapComponentStatus(component.status);
const componentDescription =
componentIndicator === "none"
? undefined
: `${component.name ?? "Component"}: ${formatComponentLabel(component.status)}`;
return {
indicator: componentIndicator,
description: componentDescription,
};
}
return {
indicator: summaryIndicator,
description: data.status?.description,
};
} catch {
clear();
return { indicator: "unknown" };
}
}
/**
* Fetch Gemini status from Google Workspace status API
*/
async function fetchGeminiStatus(deps: Dependencies): Promise<ProviderStatus> {
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
try {
const res = await deps.fetch(GOOGLE_STATUS_URL, { signal: controller.signal });
clear();
if (!res.ok) return { indicator: "unknown" };
const incidents = (await res.json()) as Array<{
end?: string;
currently_affected_products?: Array<{ id: string }>;
affected_products?: Array<{ id: string }>;
most_recent_update?: { status?: string };
status_impact?: string;
external_desc?: string;
}>;
const activeIncidents = incidents.filter((inc) => {
if (inc.end) return false;
const affected = inc.currently_affected_products || inc.affected_products || [];
return affected.some((p) => p.id === GEMINI_PRODUCT_ID);
});
if (activeIncidents.length === 0) {
return { indicator: "none" };
}
let worstIndicator: StatusIndicator = "minor";
let description: string | undefined;
for (const inc of activeIncidents) {
const status = inc.most_recent_update?.status || inc.status_impact;
if (status === "SERVICE_OUTAGE") {
worstIndicator = "critical";
description = inc.external_desc;
} else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") {
worstIndicator = "major";
description = inc.external_desc;
}
}
return { indicator: worstIndicator, description };
} catch {
clear();
return { indicator: "unknown" };
}
}
/**
* Fetch status for a provider
*/
export async function fetchProviderStatus(provider: ProviderName, deps: Dependencies): Promise<ProviderStatus> {
const statusConfig = PROVIDER_METADATA[provider]?.status;
if (!statusConfig) {
return { indicator: "none" };
}
if (statusConfig.type === "google-workspace") {
return fetchGeminiStatus(deps);
}
return fetchStatuspageStatus(statusConfig.url, deps, statusConfig);
}
/**
* Get emoji for a status indicator
*/
export function getStatusEmoji(status?: ProviderStatus): string {
if (!status) return "";
switch (status.indicator) {
case "none":
return "✅";
case "minor":
return "⚠️";
case "major":
return "🟠";
case "critical":
return "🔴";
case "maintenance":
return "🔧";
default:
return "";
}
}
@@ -0,0 +1,61 @@
/**
* Storage abstraction for settings and cache persistence.
*/
import * as fs from "node:fs";
import * as path from "node:path";
export interface StorageAdapter {
readFile(path: string): string | undefined;
writeFile(path: string, contents: string): void;
writeFileExclusive(path: string, contents: string): boolean;
exists(path: string): boolean;
removeFile(path: string): void;
ensureDir(path: string): void;
}
export function createFsStorage(): StorageAdapter {
return {
readFile(filePath: string): string | undefined {
try {
return fs.readFileSync(filePath, "utf-8");
} catch {
return undefined;
}
},
writeFile(filePath: string, contents: string): void {
fs.writeFileSync(filePath, contents, "utf-8");
},
writeFileExclusive(filePath: string, contents: string): boolean {
try {
fs.writeFileSync(filePath, contents, { flag: "wx" });
return true;
} catch {
return false;
}
},
exists(filePath: string): boolean {
return fs.existsSync(filePath);
},
removeFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch {
// Ignore remove errors
}
},
ensureDir(dirPath: string): void {
fs.mkdirSync(path.resolve(dirPath), { recursive: true });
},
};
}
let activeStorage: StorageAdapter = createFsStorage();
export function getStorage(): StorageAdapter {
return activeStorage;
}
export function setStorage(storage: StorageAdapter): void {
activeStorage = storage;
}
@@ -0,0 +1,60 @@
/**
* File lock helpers for storage-backed locks.
*/
import { getStorage } from "../storage.js";
export function tryAcquireFileLock(lockPath: string, staleAfterMs: number): boolean {
const storage = getStorage();
try {
if (storage.writeFileExclusive(lockPath, String(Date.now()))) {
return true;
}
} catch {
// ignore
}
try {
if (storage.exists(lockPath)) {
const lockContent = storage.readFile(lockPath) ?? "";
const lockTime = parseInt(lockContent, 10);
if (Date.now() - lockTime > staleAfterMs) {
storage.writeFile(lockPath, String(Date.now()));
return true;
}
}
} catch {
// Ignore, lock is held by another process
}
return false;
}
export function releaseFileLock(lockPath: string): void {
const storage = getStorage();
try {
if (storage.exists(lockPath)) {
storage.removeFile(lockPath);
}
} catch {
// Ignore
}
}
export async function waitForLockRelease(
lockPath: string,
maxWaitMs: number,
pollMs: number = 100
): Promise<boolean> {
const storage = getStorage();
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
if (!storage.exists(lockPath)) {
return true;
}
}
return false;
}
@@ -0,0 +1,33 @@
/**
* Core types for the sub-bar extension
*/
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
export type {
ProviderName,
StatusIndicator,
ProviderStatus,
RateWindow,
UsageSnapshot,
UsageError,
UsageErrorCode,
ProviderUsageEntry,
SubCoreState,
SubCoreEvents,
} from "../../src/shared.js";
export { PROVIDERS } from "../../src/shared.js";
/**
* Dependencies that can be injected for testing
*/
export interface Dependencies {
fetch: typeof globalThis.fetch;
readFile: (path: string) => string | undefined;
fileExists: (path: string) => boolean;
// Use static commands/args only (no user-controlled input).
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
homedir: () => string;
env: NodeJS.ProcessEnv;
}
@@ -0,0 +1,290 @@
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
import {
Input,
fuzzyFilter,
getEditorKeybindings,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@mariozechner/pi-tui";
export interface SettingsListOptions {
enableSearch?: boolean;
}
export const CUSTOM_OPTION = "__custom__";
export const CUSTOM_LABEL = "custom";
export type { SettingItem, SettingsListTheme };
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(this.theme.hint(" No matching settings"));
this.addHintLine(lines);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
displayItems.length - this.maxVisible,
),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
const optionLines = isSelected && item.values && item.values.length > 0
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
: null;
const valueText = optionLines
? optionLines[0] ?? ""
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
const line = prefix + labelText + separator + valueText;
lines.push(truncateToWidth(line, width, ""));
if (optionLines && optionLines.length > 1) {
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
for (const continuation of optionLines.slice(1)) {
lines.push(truncateToWidth(indent + continuation, width, ""));
}
}
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrapWidth = Math.max(1, width - 4);
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
for (const line of wrappedDesc) {
const prefixed = ` ${line}`;
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
}
}
// Add hint
this.addHintLine(lines);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
const kb = getEditorKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "cursorLeft")) {
this.stepValue(-1);
} else if (kb.matches(data, "cursorRight")) {
this.stepValue(1);
} else if (kb.matches(data, "selectConfirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private stepValue(direction: -1 | 1): void {
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
const item = displayItems[this.selectedIndex];
if (!item || !item.values || item.values.length === 0) return;
const values = item.values;
let currentIndex = values.indexOf(item.currentValue);
if (currentIndex === -1) {
currentIndex = direction > 0 ? 0 : values.length - 1;
}
const nextIndex = (currentIndex + direction + values.length) % values.length;
const newValue = values[nextIndex];
if (newValue === CUSTOM_OPTION) {
item.currentValue = newValue;
this.onChange(item.id, newValue);
return;
}
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
if (item.submenu && hasCustom) {
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
this.openSubmenu(item);
}
return;
}
if (item.submenu) {
this.openSubmenu(item);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private formatOptionsInline(item: SettingItem, values: string[]): string {
const separator = this.theme.description(" • ");
const hasCustom = values.includes(CUSTOM_OPTION);
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
return values
.map((value) => {
const label = value === CUSTOM_OPTION
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
: value;
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
return this.theme.value(label, selected);
})
.join(separator);
}
private openSubmenu(item: SettingItem): void {
if (!item.submenu) return;
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
}
private addHintLine(lines: string[]): void {
lines.push("");
lines.push(
this.theme.hint(
this.searchEnabled
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
),
);
}
}
@@ -0,0 +1,250 @@
/**
* Usage refresh and provider selection controller.
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { ProviderName, UsageSnapshot } from "../types.js";
import type { Settings } from "../settings-types.js";
import { detectProviderFromModel } from "../providers/detection.js";
import { isExpectedMissingData } from "../errors.js";
import { formatElapsedSince } from "../utils.js";
import { fetchUsageForProvider, refreshStatusForProvider } from "./fetch.js";
import type { Dependencies } from "../types.js";
import { getCachedData, readCache } from "../cache.js";
import { hasProviderCredentials } from "../providers/registry.js";
export interface UsageControllerState {
currentProvider?: ProviderName;
cachedUsage?: UsageSnapshot;
lastSuccessAt?: number;
providerCycleIndex: number;
}
export interface UsageUpdate {
provider?: ProviderName;
usage?: UsageSnapshot;
}
export type UsageUpdateHandler = (update: UsageUpdate) => void;
export function createUsageController(deps: Dependencies) {
function isProviderAvailable(
settings: Settings,
provider: ProviderName,
options?: { skipCredentials?: boolean }
): boolean {
const setting = settings.providers[provider];
if (setting.enabled === "off" || setting.enabled === false) return false;
if (setting.enabled === "on" || setting.enabled === true) return true;
if (options?.skipCredentials) return true;
return hasProviderCredentials(provider, deps);
}
function getEnabledProviders(settings: Settings): ProviderName[] {
return settings.providerOrder.filter((p) => isProviderAvailable(settings, p));
}
function resolveProvider(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
options?: { skipCredentials?: boolean }
): ProviderName | undefined {
const detected = detectProviderFromModel(ctx.model);
if (detected && isProviderAvailable(settings, detected, options)) {
return detected;
}
return undefined;
}
function emitUpdate(state: UsageControllerState, onUpdate: UsageUpdateHandler): void {
onUpdate({
provider: state.currentProvider,
usage: state.cachedUsage,
});
}
async function refresh(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler,
options?: { force?: boolean; allowStaleCache?: boolean; forceStatus?: boolean; skipFetch?: boolean }
): Promise<void> {
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
if (!provider) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const providerChanged = provider !== state.currentProvider;
state.currentProvider = provider;
if (providerChanged) {
state.cachedUsage = undefined;
}
const cache = readCache();
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
if (!cachedEntry && options?.allowStaleCache) {
cachedEntry = cache[provider] ?? null;
}
if (cachedEntry?.usage) {
state.cachedUsage = {
...cachedEntry.usage,
status: cachedEntry.status,
lastSuccessAt: cachedEntry.fetchedAt,
};
if (!cachedEntry.usage.error) {
state.lastSuccessAt = cachedEntry.fetchedAt;
}
}
emitUpdate(state, onUpdate);
if (options?.skipFetch) {
return;
}
const result = await fetchUsageForProvider(deps, settings, provider, options);
const error = result.usage?.error;
const fetchError = Boolean(error && !isExpectedMissingData(error));
if (fetchError) {
let fallback = state.cachedUsage;
let fallbackFetchedAt = state.lastSuccessAt;
if (!fallback || fallback.windows.length === 0) {
const cachedEntry = cache[provider];
const cachedUsage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
fallback = cachedUsage && cachedUsage.windows.length > 0 ? cachedUsage : undefined;
if (cachedEntry?.fetchedAt) fallbackFetchedAt = cachedEntry.fetchedAt;
}
if (fallback && fallback.windows.length > 0) {
const lastSuccessAt = fallbackFetchedAt ?? state.lastSuccessAt;
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
const description = elapsed ? (elapsed === "just now" ? "just now" : `${elapsed} ago`) : "Fetch failed";
state.cachedUsage = {
...fallback,
lastSuccessAt,
error,
status: { indicator: "minor", description },
};
} else {
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
}
} else {
const successAt = Date.now();
state.cachedUsage = result.usage
? { ...result.usage, status: result.status, lastSuccessAt: successAt }
: undefined;
if (result.usage && !result.usage.error) {
state.lastSuccessAt = successAt;
}
}
emitUpdate(state, onUpdate);
}
async function refreshStatus(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler,
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
): Promise<void> {
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
if (!provider) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const providerChanged = provider !== state.currentProvider;
state.currentProvider = provider;
if (providerChanged) {
state.cachedUsage = undefined;
}
const cache = readCache();
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
if (!cachedEntry && options?.allowStaleCache) {
cachedEntry = cache[provider] ?? null;
}
if (cachedEntry?.usage) {
state.cachedUsage = {
...cachedEntry.usage,
status: cachedEntry.status,
lastSuccessAt: cachedEntry.fetchedAt,
};
if (!cachedEntry.usage.error) {
state.lastSuccessAt = cachedEntry.fetchedAt;
}
}
if (options?.skipFetch) {
emitUpdate(state, onUpdate);
return;
}
const status = await refreshStatusForProvider(deps, settings, provider, { force: options?.force });
if (status && state.cachedUsage) {
state.cachedUsage = { ...state.cachedUsage, status };
}
emitUpdate(state, onUpdate);
}
async function cycleProvider(
ctx: ExtensionContext,
settings: Settings,
state: UsageControllerState,
onUpdate: UsageUpdateHandler
): Promise<void> {
const enabledProviders = getEnabledProviders(settings);
if (enabledProviders.length === 0) {
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
return;
}
const currentIndex = state.currentProvider
? enabledProviders.indexOf(state.currentProvider)
: -1;
if (currentIndex >= 0) {
state.providerCycleIndex = currentIndex;
}
const total = enabledProviders.length;
for (let i = 0; i < total; i += 1) {
state.providerCycleIndex = (state.providerCycleIndex + 1) % total;
const nextProvider = enabledProviders[state.providerCycleIndex];
const result = await fetchUsageForProvider(deps, settings, nextProvider);
if (!isUsageAvailable(result.usage)) {
continue;
}
state.currentProvider = nextProvider;
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
emitUpdate(state, onUpdate);
return;
}
state.currentProvider = undefined;
state.cachedUsage = undefined;
emitUpdate(state, onUpdate);
}
function isUsageAvailable(usage: UsageSnapshot | undefined): usage is UsageSnapshot {
if (!usage) return false;
if (usage.windows.length > 0) return true;
if (!usage.error) return false;
return !isExpectedMissingData(usage.error);
}
return {
getEnabledProviders,
resolveProvider,
refresh,
refreshStatus,
cycleProvider,
};
}
@@ -0,0 +1,215 @@
/**
* Usage fetching helpers with cache integration.
*/
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "../types.js";
import type { Settings } from "../settings-types.js";
import type { ProviderUsageEntry } from "./types.js";
import { createProvider } from "../providers/registry.js";
import { fetchWithCache, getCachedData, readCache, updateCacheStatus, type Cache } from "../cache.js";
import { fetchProviderStatusWithFallback, providerHasStatus } from "../providers/status.js";
import { hasProviderCredentials } from "../providers/registry.js";
import { isExpectedMissingData } from "../errors.js";
export function getCacheTtlMs(settings: Settings): number {
return settings.behavior.refreshInterval * 1000;
}
export function getMinRefreshIntervalMs(settings: Settings): number {
return settings.behavior.minRefreshInterval * 1000;
}
export function getStatusCacheTtlMs(settings: Settings): number {
return settings.statusRefresh.refreshInterval * 1000;
}
export function getStatusMinRefreshIntervalMs(settings: Settings): number {
return settings.statusRefresh.minRefreshInterval * 1000;
}
const PROVIDER_FETCH_CONCURRENCY = 3;
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const currentIndex = nextIndex++;
if (currentIndex >= items.length) {
return;
}
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
});
await Promise.all(workers);
return results;
}
function resolveStatusFetchedAt(entry?: { fetchedAt: number; statusFetchedAt?: number } | null): number | undefined {
if (!entry) return undefined;
return entry.statusFetchedAt ?? entry.fetchedAt;
}
function isWithinMinInterval(fetchedAt: number | undefined, minIntervalMs: number): boolean {
if (!fetchedAt || minIntervalMs <= 0) return false;
return Date.now() - fetchedAt < minIntervalMs;
}
function shouldRefreshStatus(
settings: Settings,
entry?: { fetchedAt: number; statusFetchedAt?: number } | null,
options?: { force?: boolean }
): boolean {
const fetchedAt = resolveStatusFetchedAt(entry);
const minIntervalMs = getStatusMinRefreshIntervalMs(settings);
if (isWithinMinInterval(fetchedAt, minIntervalMs)) return false;
if (options?.force) return true;
const ttlMs = getStatusCacheTtlMs(settings);
if (ttlMs <= 0) return true;
if (!fetchedAt) return true;
return Date.now() - fetchedAt >= ttlMs;
}
export async function refreshStatusForProvider(
deps: Dependencies,
settings: Settings,
provider: ProviderName,
options?: { force?: boolean }
): Promise<ProviderStatus | undefined> {
const enabledSetting = settings.providers[provider].enabled;
if (enabledSetting === "off" || enabledSetting === false) {
return undefined;
}
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
return undefined;
}
if (!settings.providers[provider].fetchStatus) {
return undefined;
}
const cache = readCache();
const entry = cache[provider];
const providerInstance = createProvider(provider);
const shouldFetch = providerHasStatus(provider, providerInstance) && shouldRefreshStatus(settings, entry, options);
if (!shouldFetch) {
return entry?.status;
}
const status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
await updateCacheStatus(provider, status, { statusFetchedAt: Date.now() });
return status;
}
export async function fetchUsageForProvider(
deps: Dependencies,
settings: Settings,
provider: ProviderName,
options?: { force?: boolean; forceStatus?: boolean }
): Promise<{ usage?: UsageSnapshot; status?: ProviderStatus }> {
const enabledSetting = settings.providers[provider].enabled;
if (enabledSetting === "off" || enabledSetting === false) {
return {};
}
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
return {};
}
const ttlMs = getCacheTtlMs(settings);
const cache = readCache();
const cachedEntry = cache[provider];
const cachedStatus = cachedEntry?.status;
const minIntervalMs = getMinRefreshIntervalMs(settings);
if (cachedEntry?.usage && isWithinMinInterval(cachedEntry.fetchedAt, minIntervalMs)) {
const usage = { ...cachedEntry.usage, status: cachedEntry.status } as UsageSnapshot;
return { usage, status: cachedEntry.status };
}
const providerInstance = createProvider(provider);
const shouldFetchStatus = Boolean(options?.forceStatus)
&& settings.providers[provider].fetchStatus
&& providerHasStatus(provider, providerInstance);
if (!options?.force) {
const cachedUsage = await getCachedData(provider, ttlMs, cache);
if (cachedUsage) {
let status = cachedUsage.status;
if (shouldFetchStatus) {
status = await refreshStatusForProvider(deps, settings, provider, { force: options?.forceStatus ?? options?.force });
}
const usage = cachedUsage.usage ? { ...cachedUsage.usage, status } : undefined;
return { usage, status };
}
}
return fetchWithCache(
provider,
ttlMs,
async () => {
const usage = await providerInstance.fetchUsage(deps);
let status = cachedStatus;
let statusFetchedAt = resolveStatusFetchedAt(cachedEntry);
if (shouldFetchStatus) {
status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
statusFetchedAt = Date.now();
} else if (!status) {
status = { indicator: "none" as const };
}
return { usage, status, statusFetchedAt };
},
options,
);
}
export async function getCachedUsageEntry(
provider: ProviderName,
settings: Settings,
cacheSnapshot?: Cache
): Promise<ProviderUsageEntry | undefined> {
const ttlMs = getCacheTtlMs(settings);
const cachedEntry = await getCachedData(provider, ttlMs, cacheSnapshot);
const usage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
return undefined;
}
return { provider, usage };
}
export async function getCachedUsageEntries(
providers: ProviderName[],
settings: Settings
): Promise<ProviderUsageEntry[]> {
const cache = readCache();
const entries: ProviderUsageEntry[] = [];
for (const provider of providers) {
const entry = await getCachedUsageEntry(provider, settings, cache);
if (entry) {
entries.push(entry);
}
}
return entries;
}
export async function fetchUsageEntries(
deps: Dependencies,
settings: Settings,
providers: ProviderName[],
options?: { force?: boolean }
): Promise<ProviderUsageEntry[]> {
const concurrency = Math.max(1, Math.min(PROVIDER_FETCH_CONCURRENCY, providers.length));
const results = await mapWithConcurrency(providers, concurrency, async (provider) => {
const result = await fetchUsageForProvider(deps, settings, provider, options);
const usage = result.usage
? ({ ...result.usage, status: result.status } as UsageSnapshot)
: undefined;
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
return undefined;
}
return { provider, usage } as ProviderUsageEntry;
});
return results.filter(Boolean) as ProviderUsageEntry[];
}
@@ -0,0 +1,5 @@
/**
* Usage data types shared across modules.
*/
export type { ProviderUsageEntry } from "../../../src/shared.js";
@@ -0,0 +1,122 @@
/**
* Utility functions for the sub-bar extension
*/
import type { Dependencies } from "./types.js";
import { MODEL_MULTIPLIERS } from "./config.js";
// Only allow simple CLI names (no spaces/paths) to avoid unsafe command execution.
const SAFE_CLI_NAME = /^[a-zA-Z0-9._-]+$/;
/**
* Format a reset date as a relative time string
*/
export function formatReset(date: Date): string {
const diffMs = date.getTime() - Date.now();
if (diffMs < 0) return "now";
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
}
/**
* Format elapsed time since a timestamp (milliseconds)
*/
export function formatElapsedSince(timestamp: number): string {
const diffMs = Date.now() - timestamp;
if (diffMs < 60000) return "just now";
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
}
/**
* Strip ANSI escape codes from a string
*/
export function stripAnsi(text: string): string {
return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
}
/**
* Normalize a string into tokens for fuzzy matching
*/
export function normalizeTokens(value: string): string[] {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(" ")
.filter(Boolean);
}
// Pre-computed token entries for model multiplier matching
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
label,
multiplier,
tokens: normalizeTokens(label),
}));
/**
* Get the request multiplier for a model ID
* Uses fuzzy matching against known model names
*/
export function getModelMultiplier(modelId: string | undefined): number | undefined {
if (!modelId) return undefined;
const modelTokens = normalizeTokens(modelId);
if (modelTokens.length === 0) return undefined;
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
for (const entry of MODEL_MULTIPLIER_TOKENS) {
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
if (!isMatch) continue;
const tokenCount = entry.tokens.length;
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
bestMatch = { multiplier: entry.multiplier, tokenCount };
}
}
return bestMatch?.multiplier;
}
/**
* Check if a command exists in PATH
*/
export function whichSync(cmd: string, deps: Dependencies): string | null {
if (!SAFE_CLI_NAME.test(cmd)) {
return null;
}
try {
return deps.execFileSync("which", [cmd], { encoding: "utf-8" }).trim();
} catch {
return null;
}
}
/**
* Create an abort controller with a timeout
*/
export function createTimeoutController(timeoutMs: number): { controller: AbortController; clear: () => void } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
clear: () => clearTimeout(timeoutId),
};
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules"]
}