feat: enhanced usage providers to extract account identifiers from headers

- Enhanced usage providers to extract account identifiers from response headers.
- Fixed GitHub Copilot usage provider token handling and account identifier resolution.
- Added usage report deduplication and provider sorting features.
- Added isolated task execution with git worktree management.
This commit is contained in:
can1357
2026-01-21 23:52:03 +01:00
parent 8e708d7f4c
commit b9c9330ea1
12 changed files with 414 additions and 183 deletions
+12 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added usage tracking system with normalized schema for provider quota/limit endpoints
@@ -12,8 +11,16 @@
- Added OpenAI Codex usage provider for primary and secondary rate limit windows
- Added ZAI usage provider for token and request quota tracking
### Changed
- Updated Claude usage provider to extract account identifiers from response headers
- Updated GitHub Copilot usage provider to include account identifiers in usage reports
- Updated Google Gemini CLI usage provider to handle missing reset time gracefully
### Fixed
- Fixed GitHub Copilot usage provider to simplify token handling and improve reliability
- Fixed GitHub Copilot usage provider to properly resolve account identifiers for OAuth credentials
- Fixed API validation errors when sending empty user messages (resume with `.`) across all providers:
- Google Cloud Code Assist (google-shared.ts)
- OpenAI Responses API (openai-responses.ts)
@@ -21,6 +28,10 @@
- Cursor (cursor.ts)
- Amazon Bedrock (amazon-bedrock.ts)
- Clamped OpenAI Codex reasoning effort "minimal" to "low" for gpt-5.2 models to avoid API errors
- Fixed GitHub Copilot usage fallback to internal quota endpoints when billing usage is unavailable
- Fixed GitHub Copilot usage metadata to include account identifiers for report dedupe
- Fixed Anthropic usage metadata extraction to include account identifiers when provided by the usage endpoint
- Fixed Gemini CLI usage windows to consistently label quota windows for display suppression
## [6.9.69] - 2026-01-21
### Added
@@ -73,10 +73,20 @@ function getModelTier(modelId: string): string | undefined {
return undefined;
}
function parseWindow(resetTime: string | undefined, now: number): UsageWindow | undefined {
if (!resetTime) return undefined;
function parseWindow(resetTime: string | undefined, now: number): UsageWindow {
if (!resetTime) {
return {
id: "quota",
label: "Quota window",
};
}
const resetsAt = Date.parse(resetTime);
if (Number.isNaN(resetsAt)) return undefined;
if (Number.isNaN(resetsAt)) {
return {
id: "quota",
label: "Quota window",
};
}
return {
id: `reset-${resetsAt}`,
label: "Quota window",
+41 -7
View File
@@ -62,6 +62,11 @@ interface ClaudeUsageResponse {
seven_day_sonnet?: ClaudeUsageBucket | null;
}
type ClaudeUsagePayload = {
payload: ClaudeUsageResponse;
orgId?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -94,6 +99,28 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
return { utilization, resetsAt };
}
function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined {
const value = payload[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
if (!isRecord(payload)) return { accountId: orgId };
const accountId =
getPayloadString(payload, "account_id") ??
getPayloadString(payload, "accountId") ??
getPayloadString(payload, "user_id") ??
getPayloadString(payload, "userId") ??
getPayloadString(payload, "org_id") ??
getPayloadString(payload, "orgId") ??
orgId;
const email =
getPayloadString(payload, "email") ??
getPayloadString(payload, "user_email") ??
getPayloadString(payload, "userEmail");
return { accountId, email };
}
function hasUsageData(payload: ClaudeUsageResponse): boolean {
return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet);
}
@@ -103,8 +130,9 @@ async function fetchUsagePayload(
headers: Record<string, string>,
ctx: UsageFetchContext,
signal?: AbortSignal,
): Promise<ClaudeUsageResponse | null> {
): Promise<ClaudeUsagePayload | null> {
let lastPayload: ClaudeUsageResponse | null = null;
let lastOrgId: string | undefined;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await ctx.fetch(url, { headers, signal });
@@ -114,8 +142,10 @@ async function fetchUsagePayload(
}
const payload = (await response.json()) as ClaudeUsageResponse;
lastPayload = payload;
const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
lastOrgId = orgId ?? lastOrgId;
if (payload && isRecord(payload) && hasUsageData(payload)) {
return payload;
return { payload, orgId };
}
} catch (error) {
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
@@ -127,7 +157,7 @@ async function fetchUsagePayload(
}
}
return lastPayload;
return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
}
function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined {
@@ -240,8 +270,9 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
authorization: `Bearer ${credential.accessToken}`,
};
const payload = await fetchUsagePayload(url, headers, ctx, params.signal);
if (!payload || !isRecord(payload)) return cachedValue;
const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
if (!payloadResult || !isRecord(payloadResult.payload)) return cachedValue;
const { payload, orgId } = payloadResult;
const fiveHour = parseBucket(payload.five_hour);
const sevenDay = parseBucket(payload.seven_day);
@@ -296,14 +327,17 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
].filter((limit): limit is UsageLimit => limit !== null);
if (limits.length === 0) return cachedValue;
const identity = extractUsageIdentity(payload, orgId);
const accountId = identity.accountId ?? credential.accountId;
const email = identity.email ?? credential.email;
const report: UsageReport = {
provider: params.provider,
fetchedAt: now,
limits,
metadata: {
accountId: credential.accountId,
email: credential.email,
accountId,
email,
endpoint: url,
},
raw: payload,
+66 -89
View File
@@ -49,11 +49,6 @@ type CopilotUsageResponse = {
quota_snapshots: CopilotQuotaSnapshots;
};
type CopilotTokenResponse = {
token: string;
expires_at: number;
};
type BillingUsageItem = {
product: string;
sku: string;
@@ -101,13 +96,6 @@ function resolveGitHubApiBaseUrl(params: UsageFetchParams): string {
return `https://api.${enterpriseUrl}`;
}
function resolveCopilotApiBaseUrl(params: UsageFetchParams): string {
if (params.baseUrl) return params.baseUrl.replace(/\/$/, "");
const enterpriseUrl = params.credential.enterpriseUrl?.trim();
if (enterpriseUrl) return `https://api.${enterpriseUrl}`;
return "https://api.individual.githubcopilot.com";
}
function buildCacheKey(params: UsageFetchParams): string {
const parts: string[] = [params.provider];
const { credential } = params;
@@ -221,68 +209,21 @@ async function resolveGitHubUsername(
}
}
async function exchangeForCopilotToken(
ctx: UsageFetchContext,
baseUrl: string,
oauthToken: string,
signal?: AbortSignal,
): Promise<CopilotTokenResponse | null> {
try {
const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/v2/token`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${oauthToken}`,
...COPILOT_HEADERS,
},
signal,
});
if (!isRecord(data)) return null;
const token = typeof data.token === "string" ? data.token : undefined;
const expiresAt = toNumber(data.expires_at);
if (!token || !expiresAt) return null;
return { token, expires_at: expiresAt };
} catch {
return null;
}
}
async function fetchInternalUsage(
ctx: UsageFetchContext,
baseUrl: string,
oauthToken: string,
accessToken: string | undefined,
expiresAt: number | undefined,
githubApiBaseUrl: string,
token: string,
signal?: AbortSignal,
): Promise<CopilotUsageResponse> {
const requestWithToken = async (token: string, legacy: boolean) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: legacy ? `token ${token}` : `Bearer ${token}`,
...COPILOT_HEADERS,
};
const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/user`, { headers, signal });
if (!isRecord(data)) throw new Error("Invalid Copilot usage response");
return data as CopilotUsageResponse;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`,
...COPILOT_HEADERS,
};
const now = ctx.now();
if (accessToken && expiresAt && accessToken !== oauthToken && expiresAt > now) {
try {
return await requestWithToken(accessToken, false);
} catch {
// Ignore and try other strategies.
}
}
try {
return await requestWithToken(oauthToken, true);
} catch {
const exchanged = await exchangeForCopilotToken(ctx, baseUrl, oauthToken, signal);
if (!exchanged) throw new Error("Copilot usage token exchange failed");
return requestWithToken(exchanged.token, false);
}
const data = await fetchJson(ctx, `${githubApiBaseUrl}/copilot_internal/user`, { headers, signal });
if (!isRecord(data)) throw new Error("Invalid Copilot usage response");
return data as CopilotUsageResponse;
}
async function fetchBillingUsage(
@@ -315,6 +256,7 @@ function buildLimitFromQuota(
quota: CopilotQuotaDetail,
plan: string,
window: UsageWindow | undefined,
accountId?: string,
): UsageLimit {
const used = quota.unlimited ? undefined : Math.max(0, quota.entitlement - quota.remaining);
const limit = quota.unlimited ? undefined : quota.entitlement;
@@ -329,6 +271,7 @@ function buildLimitFromQuota(
label,
scope: {
provider: "github-copilot",
accountId,
tier: plan,
windowId: window?.id,
},
@@ -342,21 +285,22 @@ function buildLimitFromQuota(
function normalizeQuotaSnapshots(
data: CopilotUsageResponse,
now: number,
accountId?: string,
): { limits: UsageLimit[]; window?: UsageWindow } {
const window = buildWindow(data.quota_reset_date, now);
const snapshots = data.quota_snapshots ?? {};
const limits: UsageLimit[] = [];
const premium = parseQuotaDetail(snapshots.premium_interactions);
if (premium) {
limits.push(buildLimitFromQuota("premium", "Premium Requests", premium, data.copilot_plan, window));
limits.push(buildLimitFromQuota("premium", "Premium Requests", premium, data.copilot_plan, window, accountId));
}
const chat = parseQuotaDetail(snapshots.chat);
if (chat && !chat.unlimited) {
limits.push(buildLimitFromQuota("chat", "Chat Requests", chat, data.copilot_plan, window));
limits.push(buildLimitFromQuota("chat", "Chat Requests", chat, data.copilot_plan, window, accountId));
}
const completions = parseQuotaDetail(snapshots.completions);
if (completions && !completions.unlimited) {
limits.push(buildLimitFromQuota("completions", "Completions", completions, data.copilot_plan, window));
limits.push(buildLimitFromQuota("completions", "Completions", completions, data.copilot_plan, window, accountId));
}
return { limits, window };
}
@@ -437,26 +381,36 @@ export const githubCopilotUsageProvider: UsageProvider = {
const cached = await ctx.cache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.value;
const baseUrl =
params.credential.type === "api_key" ? resolveGitHubApiBaseUrl(params) : resolveCopilotApiBaseUrl(params);
const githubApiBaseUrl = resolveGitHubApiBaseUrl(params);
let report: UsageReport | null = null;
if (params.credential.type === "api_key") {
let username =
let username: string | undefined;
const candidate =
params.credential.accountId || params.credential.metadata?.username || params.credential.metadata?.user;
if ((!username || typeof username !== "string" || !username.trim()) && params.credential.apiKey) {
username = await resolveGitHubUsername(ctx, baseUrl, params.credential.apiKey, params.signal);
if (typeof candidate === "string" && candidate.trim()) {
username = candidate.trim();
}
if (typeof username !== "string" || !username.trim()) {
if (!username && params.credential.apiKey) {
username = await resolveGitHubUsername(ctx, githubApiBaseUrl, params.credential.apiKey, params.signal);
}
if (!username) {
ctx.logger?.warn("Copilot usage requires username for billing API", { provider: params.provider });
} else if (params.credential.apiKey) {
try {
const billing = await fetchBillingUsage(ctx, baseUrl, username, params.credential.apiKey, params.signal);
const billing = await fetchBillingUsage(
ctx,
githubApiBaseUrl,
username,
params.credential.apiKey,
params.signal,
);
report = {
provider: "github-copilot",
fetchedAt: now,
limits: normalizeBillingUsage(billing),
metadata: {
accountId: billing.user,
account: billing.user,
period: billing.timePeriod,
},
@@ -465,26 +419,49 @@ export const githubCopilotUsageProvider: UsageProvider = {
ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) });
}
}
if (!report && params.credential.apiKey) {
try {
const usage = await fetchInternalUsage(ctx, githubApiBaseUrl, params.credential.apiKey, params.signal);
const normalized = normalizeQuotaSnapshots(usage, now, username);
report = {
provider: "github-copilot",
fetchedAt: now,
limits: normalized.limits,
metadata: {
accountId: username,
plan: usage.copilot_plan,
quotaResetDate: usage.quota_reset_date,
},
raw: usage,
};
} catch (error) {
ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) });
}
}
} else {
const { refreshToken, accessToken, expiresAt } = params.credential;
const { refreshToken, accessToken } = params.credential;
if (!refreshToken && !accessToken) return null;
const oauthToken = refreshToken || accessToken;
if (!oauthToken) return null;
const githubToken = refreshToken ?? accessToken;
if (!githubToken) return null;
try {
const usage = await fetchInternalUsage(
ctx,
baseUrl,
oauthToken,
accessToken ?? undefined,
expiresAt ?? undefined,
params.signal,
);
const normalized = normalizeQuotaSnapshots(usage, now);
const usage = await fetchInternalUsage(ctx, githubApiBaseUrl, githubToken, params.signal);
let accountId = params.credential.accountId;
if (!accountId && refreshToken) {
accountId = await resolveGitHubUsername(ctx, githubApiBaseUrl, refreshToken, params.signal);
}
if (!accountId && accessToken) {
accountId = await resolveGitHubUsername(ctx, githubApiBaseUrl, accessToken, params.signal);
}
const normalized = normalizeQuotaSnapshots(usage, now, accountId);
report = {
provider: "github-copilot",
fetchedAt: now,
limits: normalized.limits,
metadata: {
accountId,
email: params.credential.email,
plan: usage.copilot_plan,
quotaResetDate: usage.quota_reset_date,
},
+94 -52
View File
@@ -1,9 +1,15 @@
# Changelog
## [Unreleased]
### Added
- Added usage report deduplication to prevent duplicate account entries
- Added debug logging for usage fetch operations to aid diagnostics
- Added provider sorting in usage display by total usage amount
- Added `isolated` parameter to task tool for running each task in separate git worktrees
- Added git worktree management for isolated task execution with patch generation
- Added patch application system that applies changes only when all patches are valid
- Added working directory information to environment info display
- Added `/usage` command to display provider usage and limits
- Added support for multiple usage providers beyond Codex
- Added usage report caching with configurable TTL
@@ -14,9 +20,16 @@
- Added support for jq-like queries when reading JSON outputs
- Added offset and limit parameters for reading specific line ranges from outputs
- Added "." and "c" shortcuts to continue agent without sending visible message
- Added debug logging for usage fetch results to aid /usage diagnostics
### Changed
- Updated discoverSkills function to return object with skills property
- Enhanced usage report merging to combine limits and metadata from duplicate accounts
- Improved OAuth credential handling to preserve existing fields when updating
- Removed cd function from Python prelude to encourage using cwd parameter
- Updated task tool to generate and apply patches when running in isolated mode
- Enhanced task tool rendering to display isolated execution status and patch paths
- Updated system prompt structure and formatting for better readability
- Reorganized tool hierarchy and discipline sections
- Added parallel work guidance for task-based workflows
@@ -32,12 +45,17 @@
### Fixed
- Fixed TypeScript error in bash executor by properly typing caught exception
- Fixed usage display ordering to show providers with lowest usage first
- Fixed task tool result rendering to show fallback text when no results are available
- Fixed external editor to work properly on Unix systems by correctly handling terminal I/O
- Fixed external editor to show warning message when it fails to open instead of silently failing
- Fixed find tool to properly handle no matches case without treating as error
- Fixed find tool to wait for fd exit so error messages no longer report exit null
- Fixed read tool to properly handle no matches case without treating as error
- Fixed orphaned Python kernel gateway processes not being killed on process exit
- Fixed /usage provider ordering to sort by aggregate usage (most used last)
- Fixed /usage account dedupe to collapse identical accounts using usage metadata
## [6.9.69] - 2026-01-21
@@ -81,6 +99,7 @@
- Fixed patch indentation normalization for fuzzy matches, tab/space diffs, and ambiguous context alignment
## [6.9.0] - 2026-01-21
### Removed
- Removed Git tool and all related functionality
@@ -91,6 +110,7 @@
- Removed @oh-my-pi/pi-git-tool dependency
## [6.8.5] - 2026-01-21
### Breaking Changes
- Changed timeout parameter from seconds to milliseconds in Python tool
@@ -102,6 +122,7 @@
- Improved streaming output handling and buffer management
## [6.8.4] - 2026-01-21
### Changed
- Updated output sink to properly handle large outputs
@@ -183,6 +204,7 @@
- Updated temporary file cleanup to use secure async removal methods
## [6.7.67] - 2026-01-19
### Added
- Added normative rewrite setting to control tool call argument normalization in session history
@@ -231,7 +253,7 @@
- Patch application handles repeated context blocks, preserves original indentation on fuzzy match
- Ambiguous context matching resolves duplicates using adjacent @@ anchor positioning
- Patch parser handles bare *** terminators, model hallucination markers, line hint ranges
- Patch parser handles bare \*\*\* terminators, model hallucination markers, line hint ranges
- Function context matching handles signatures with and without empty parentheses
- Fixed session title generation to respect OMP_NO_TITLE environment variable
- Fixed Python module discovery to use import.meta.dir for ES module compatibility
@@ -243,6 +265,7 @@
- Fixed MCP tool path formatting to correctly display provider information
## [6.2.0] - 2026-01-19
### Changed
- Improved LSP batching to coalesce formatting and diagnostics for parallel edits
@@ -280,12 +303,14 @@
- Fixed TTSR abbreviation expansion from TTSR to Time Traveling Stream Rules
## [5.8.0] - 2026-01-19
### Changed
- Updated WASM loading to use streaming for development environments with base64 fallback
- Added scripts directory to published package files
## [5.7.68] - 2026-01-18
### Changed
- Updated WASM loading to use base64-encoded WASM for better compatibility with compiled binaries
@@ -295,12 +320,14 @@
- Fixed WASM loading issues in compiled binary builds
## [5.7.67] - 2026-01-18
### Changed
- Replaced external photon-node dependency with vendored WebAssembly implementation
- Updated image processing to use local photon library for better performance
## [5.6.70] - 2026-01-18
### Added
- Added support for loading Python prelude extension modules from user and project directories
@@ -373,7 +400,7 @@
- Enhanced Python kernel availability checking with faster validation
- Optimized Python environment warming to avoid blocking during tool initialization
- Reorganized settings interface into behavior, tools, display, voice, status, lsp, and exa tabs
- Migrated environment variables from PI_ to OMP_ prefix with automatic migration
- Migrated environment variables from PI* to OMP* prefix with automatic migration
- Updated model selector to use TabBar component for provider navigation
- Changed role badges to inverted style with colored backgrounds
- Added support for /models command alias in addition to /model
@@ -406,11 +433,13 @@
- Enhanced Python gateway environment filtering to exclude sensitive API keys and Windows system paths
## [5.5.0] - 2026-01-18
### Changed
- Updated task execution guidelines to improve prompt framing and parallelization instructions
## [5.4.2] - 2026-01-16
### Changed
- Updated model resolution to accept pre-serialized settings for better performance
@@ -418,11 +447,13 @@
- Enhanced edit tool documentation with clear use cases for bash alternatives
## [5.3.0] - 2026-01-15
### Changed
- Expanded bash tool guidance to explicitly list appropriate use cases including file operations, build commands, and process management
## [5.2.1] - 2026-01-14
### Fixed
- Fixed stale diagnostic results by tracking diagnostic versions before file sync operations
@@ -466,6 +497,7 @@
- Fixed session selector page up/down navigation
## [5.0.1] - 2026-01-12
### Changed
- Replaced wasm-vips with Photon for more stable WASM image processing
@@ -492,6 +524,7 @@
- Move `sharp` to optional dependencies with all platform binaries to fix arm64 runtime errors
## [4.7.0] - 2026-01-12
### Added
- Add `omp config` subcommand for managing settings (`list`, `get`, `set`, `reset`, `path`)
@@ -511,6 +544,7 @@
## [4.6.0] - 2026-01-12
### Added
- Add `/skill:name` slash commands for quick skill access (toggle via `skills.enableSkillCommands` setting)
- Add `cwd` to SessionInfo for session list display
- Add custom summarization instructions option in tree selector
@@ -518,10 +552,12 @@
- Add `shutdownRequested` and `checkShutdownRequested()` for extension-initiated shutdown
### Fixed
- Component `invalidate()` now properly rebuilds content on theme changes
- Force full re-render after returning from external editor
## [4.4.8] - 2026-01-12
### Changed
- Changed review finding priority format from numeric (0-3) to string labels (P0-P3) for clearer severity indication
@@ -541,6 +577,7 @@
- Fixed frontmatter parsing to properly report source location when YAML parsing fails
## [4.4.4] - 2026-01-11
### Added
- Added `todo_write` tool for creating and managing structured task lists during coding sessions
@@ -571,6 +608,7 @@
- Fixed prompt template loading to strip leading HTML comment metadata blocks
## [4.3.2] - 2026-01-11
### Changed
- Increased default bash output preview from 5 to 10 lines when collapsed
@@ -610,6 +648,7 @@
- Fixed serialized auth storage initialization so OAuth refreshes in subagents don't crash
## [4.2.2] - 2026-01-11
### Added
- Added persistent cache storage for Codex usage data that survives application restarts
@@ -630,6 +669,7 @@
- Removed `planner` agent command template, consolidating planning functionality into the `plan` agent
## [4.2.1] - 2026-01-11
### Added
- Added automatic discovery and listing of AGENTS.md files in the system prompt, providing agents with an authoritative list of project-specific instruction files without runtime searching
@@ -696,6 +736,7 @@
- Hardened file permissions on agent database directory (700) and database file (600) to restrict access
## [4.1.0] - 2026-01-10
### Added
- Added persistent prompt history with SQLite-backed storage and Ctrl+R search
@@ -705,6 +746,7 @@
- Fixed credential blocking logic to correctly check for remaining available credentials instead of always returning true
## [4.0.1] - 2026-01-10
### Added
- Added usage limit error detection to enable automatic credential switching when Codex accounts hit rate limits
@@ -808,11 +850,13 @@
- Extension directories in `settings.json` respect `package.json` manifests
## [3.37.0] - 2026-01-10
### Changed
- Improved bash command display to show relative paths for working directories within the current directory, and hide redundant `cd` prefix when working directory matches current directory
## [3.36.0] - 2026-01-10
### Added
- Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals
@@ -831,6 +875,7 @@
- Improved completion notification message to include session title when available
## [3.35.0] - 2026-01-09
### Added
- Added retry logic with exponential backoff for auto-compaction failures
@@ -1908,15 +1953,14 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- **Credential storage refactored**: API keys and OAuth tokens are now stored in `~/.omp/agent/auth.json` instead of `oauth.json` and `settings.json`. Existing credentials are automatically migrated on first run. ([#296](https://github.com/badlogic/pi-mono/issues/296))
- **SDK API changes** ([#296](https://github.com/badlogic/pi-mono/issues/296)):
- Added `AuthStorage` class for credential management (API keys and OAuth tokens)
- Added `ModelRegistry` class for model discovery and API key resolution
- Added `discoverAuthStorage()` and `discoverModels()` discovery functions
- `createAgentSession()` now accepts `authStorage` and `modelRegistry` options
- Removed `configureOAuthStorage()`, `defaultGetApiKey()`, `findModel()`, `discoverAvailableModels()`
- Removed `getApiKey` callback option (use `AuthStorage.setRuntimeApiKey()` for runtime overrides)
- Use `getModel()` from `@oh-my-pi/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models
- See updated [SDK documentation](docs/sdk.md) and [README](README.md)
- Added `AuthStorage` class for credential management (API keys and OAuth tokens)
- Added `ModelRegistry` class for model discovery and API key resolution
- Added `discoverAuthStorage()` and `discoverModels()` discovery functions
- `createAgentSession()` now accepts `authStorage` and `modelRegistry` options
- Removed `configureOAuthStorage()`, `defaultGetApiKey()`, `findModel()`, `discoverAvailableModels()`
- Removed `getApiKey` callback option (use `AuthStorage.setRuntimeApiKey()` for runtime overrides)
- Use `getModel()` from `@oh-my-pi/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models
- See updated [SDK documentation](docs/sdk.md) and [README](README.md)
- **Settings changes**: Removed `apiKeys` from `settings.json`. Use `auth.json` instead. ([#296](https://github.com/badlogic/pi-mono/issues/296))
@@ -1947,16 +1991,15 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Added
- **Compaction hook improvements**: The `before_compact` session event now includes:
- `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context
- `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
- `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
- Removed `apiKey` string in favor of `resolveApiKey` for more flexibility
- `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context
- `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
- `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
- Removed `apiKey` string in favor of `resolveApiKey` for more flexibility
- **SessionManager API cleanup**:
- Renamed `loadSessionFromEntries()` to `buildSessionContext()` (builds LLM context from entries, handling compaction)
- Renamed `loadEntries()` to `getEntries()` (returns defensive copy of all session entries)
- Added `buildSessionContext()` method to SessionManager
- Renamed `loadSessionFromEntries()` to `buildSessionContext()` (builds LLM context from entries, handling compaction)
- Renamed `loadEntries()` to `getEntries()` (returns defensive copy of all session entries)
- Added `buildSessionContext()` method to SessionManager
## [0.27.5] - 2025-12-24
@@ -2175,13 +2218,13 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- **Custom tools now require `index.ts` entry point**: Auto-discovered custom tools must be in a subdirectory with an `index.ts` file. The old pattern `~/.omp/agent/tools/mytool.ts` must become `~/.omp/agent/tools/mytool/index.ts`. This allows multi-file tools to import helper modules. Explicit paths via `--tool` or `settings.json` still work with any `.ts` file.
- **Hook `tool_result` event restructured**: The `ToolResultEvent` now exposes full tool result data instead of just text. ([#233](https://github.com/badlogic/pi-mono/pull/233))
- Removed: `result: string` field
- Added: `content: (TextContent | ImageContent)[]` - full content array
- Added: `details: unknown` - tool-specific details (typed per tool via discriminated union on `toolName`)
- `ToolResultEventResult.result` renamed to `ToolResultEventResult.text` (removed), use `content` instead
- Hook handlers returning `{ result: "..." }` must change to `{ content: [{ type: "text", text: "..." }] }`
- Built-in tool details types exported: `BashToolDetails`, `ReadToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, `TruncationResult`
- Type guards exported for narrowing: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`
- Removed: `result: string` field
- Added: `content: (TextContent | ImageContent)[]` - full content array
- Added: `details: unknown` - tool-specific details (typed per tool via discriminated union on `toolName`)
- `ToolResultEventResult.result` renamed to `ToolResultEventResult.text` (removed), use `content` instead
- Hook handlers returning `{ result: "..." }` must change to `{ content: [{ type: "text", text: "..." }] }`
- Built-in tool details types exported: `BashToolDetails`, `ReadToolDetails`, `GrepToolDetails`, `FindToolDetails`, `LsToolDetails`, `TruncationResult`
- Type guards exported for narrowing: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`
## [0.23.4] - 2025-12-18
@@ -2210,13 +2253,12 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- Improved system prompt documentation section with clearer pointers to specific doc files for custom models, themes, skills, hooks, custom tools, and RPC.
- Cleaned up documentation:
- `theme.md`: Added missing color tokens (`thinkingXhigh`, `bashMode`)
- `skills.md`: Rewrote with better framing and examples
- `hooks.md`: Fixed timeout/error handling docs, added import aliases section
- `custom-tools.md`: Added intro with use cases and comparison table
- `rpc.md`: Added missing `hook_error` event documentation
- `README.md`: Complete settings table, condensed philosophy section, standardized OAuth docs
- `theme.md`: Added missing color tokens (`thinkingXhigh`, `bashMode`)
- `skills.md`: Rewrote with better framing and examples
- `hooks.md`: Fixed timeout/error handling docs, added import aliases section
- `custom-tools.md`: Added intro with use cases and comparison table
- `rpc.md`: Added missing `hook_error` event documentation
- `README.md`: Complete settings table, condensed philosophy section, standardized OAuth docs
- Hooks loader now supports same import aliases as custom tools (`@sinclair/typebox`, `@oh-my-pi/pi-ai`, `@oh-my-pi/pi-tui`, `@oh-my-pi/pi-coding-agent`).
@@ -2473,12 +2515,12 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
### Changed
- **Tool output truncation**: All tools now enforce consistent truncation limits with actionable notices for the LLM. ([#134](https://github.com/badlogic/pi-mono/issues/134))
- **Limits**: 2000 lines OR 50KB (whichever hits first), never partial lines
- **read**: Shows `[Showing lines X-Y of Z. Use offset=N to continue]`. If first line exceeds 50KB, suggests bash command
- **bash**: Tail truncation with temp file. Shows `[Showing lines X-Y of Z. Full output: /tmp/...]`
- **grep**: Pre-truncates match lines to 500 chars. Shows match limit and line truncation notices
- **find/ls**: Shows result/entry limit notices
- TUI displays truncation warnings in yellow at bottom of tool output (visible even when collapsed)
- **Limits**: 2000 lines OR 50KB (whichever hits first), never partial lines
- **read**: Shows `[Showing lines X-Y of Z. Use offset=N to continue]`. If first line exceeds 50KB, suggests bash command
- **bash**: Tail truncation with temp file. Shows `[Showing lines X-Y of Z. Full output: /tmp/...]`
- **grep**: Pre-truncates match lines to 500 chars. Shows match limit and line truncation notices
- **find/ls**: Shows result/entry limit notices
- TUI displays truncation warnings in yellow at bottom of tool output (visible even when collapsed)
## [0.13.1] - 2025-12-06
@@ -2565,13 +2607,13 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
### Added
- **Context Compaction**: Long sessions can now be compacted to reduce context usage while preserving recent conversation history. ([#92](https://github.com/badlogic/pi-mono/issues/92), [docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md#context-compaction))
- `/compact [instructions]`: Manually compact context with optional custom instructions for the summary
- `/autocompact`: Toggle automatic compaction when context exceeds threshold
- Compaction summarizes older messages while keeping recent messages (default 20k tokens) verbatim
- Auto-compaction triggers when context reaches `contextWindow - reserveTokens` (default 16k reserve)
- Compacted sessions show a collapsible summary in the TUI (toggle with `o` key)
- HTML exports include compaction summaries as collapsible sections
- RPC mode supports `{"type":"compact"}` command and auto-compaction (emits compaction events)
- `/compact [instructions]`: Manually compact context with optional custom instructions for the summary
- `/autocompact`: Toggle automatic compaction when context exceeds threshold
- Compaction summarizes older messages while keeping recent messages (default 20k tokens) verbatim
- Auto-compaction triggers when context reaches `contextWindow - reserveTokens` (default 16k reserve)
- Compacted sessions show a collapsible summary in the TUI (toggle with `o` key)
- HTML exports include compaction summaries as collapsible sections
- RPC mode supports `{"type":"compact"}` command and auto-compaction (emits compaction events)
- **Branch Source Tracking**: Branched sessions now store `branchedFrom` in the session header, containing the path to the original session file. Useful for tracing session lineage.
## [0.12.5] - 2025-12-03
@@ -2608,11 +2650,11 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
### Added
- **Models**: Added support for OpenAI's new models:
- `gpt-4.1` (128K context)
- `gpt-4.1-mini` (128K context)
- `gpt-4.1-nano` (128K context)
- `o3` (200K context, reasoning model)
- `o4-mini` (200K context, reasoning model)
- `gpt-4.1` (128K context)
- `gpt-4.1-mini` (128K context)
- `gpt-4.1-nano` (128K context)
- `o3` (200K context, reasoning model)
- `o4-mini` (200K context, reasoning model)
## [0.12.0] - 2025-12-02
@@ -8,7 +8,7 @@
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@oh-my-pi/pi-coding-agent";
// Discover all skills from cwd/.omp/skills, ~/.omp/agent/skills, etc.
const allSkills = discoverSkills();
const { skills: allSkills } = await discoverSkills();
console.log(
"Discovered skills:",
allSkills.map((s) => s.name),
+151 -2
View File
@@ -801,6 +801,112 @@ export class AuthStorage {
};
}
private getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined {
const metadata = report.metadata;
if (!metadata || typeof metadata !== "object") return undefined;
const value = metadata[key];
return typeof value === "string" ? value.trim() : undefined;
}
private getUsageReportScopeAccountId(report: UsageReport): string | undefined {
const ids = new Set<string>();
for (const limit of report.limits) {
const accountId = limit.scope.accountId?.trim();
if (accountId) ids.add(accountId);
}
if (ids.size === 1) return [...ids][0];
return undefined;
}
private getUsageReportIdentifiers(report: UsageReport): string[] {
const identifiers: string[] = [];
const email = this.getUsageReportMetadataValue(report, "email");
if (email) identifiers.push(`email:${email.toLowerCase()}`);
const accountId = this.getUsageReportMetadataValue(report, "accountId");
if (accountId) identifiers.push(`account:${accountId}`);
const account = this.getUsageReportMetadataValue(report, "account");
if (account) identifiers.push(`account:${account}`);
const user = this.getUsageReportMetadataValue(report, "user");
if (user) identifiers.push(`account:${user}`);
const username = this.getUsageReportMetadataValue(report, "username");
if (username) identifiers.push(`account:${username}`);
const scopeAccountId = this.getUsageReportScopeAccountId(report);
if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`);
return identifiers.map((identifier) => `${report.provider}:${identifier.toLowerCase()}`);
}
private mergeUsageReportGroup(reports: UsageReport[]): UsageReport {
if (reports.length === 1) return reports[0];
const sorted = [...reports].sort((a, b) => {
const limitDiff = b.limits.length - a.limits.length;
if (limitDiff !== 0) return limitDiff;
return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0);
});
const base = sorted[0];
const mergedLimits = [...base.limits];
const limitIds = new Set(mergedLimits.map((limit) => limit.id));
const mergedMetadata: Record<string, unknown> = { ...(base.metadata ?? {}) };
let fetchedAt = base.fetchedAt;
for (const report of sorted.slice(1)) {
fetchedAt = Math.max(fetchedAt, report.fetchedAt);
for (const limit of report.limits) {
if (!limitIds.has(limit.id)) {
limitIds.add(limit.id);
mergedLimits.push(limit);
}
}
if (report.metadata) {
for (const [key, value] of Object.entries(report.metadata)) {
if (mergedMetadata[key] === undefined) {
mergedMetadata[key] = value;
}
}
}
}
return {
...base,
fetchedAt,
limits: mergedLimits,
metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
};
}
private dedupeUsageReports(reports: UsageReport[]): UsageReport[] {
const groups: UsageReport[][] = [];
const idToGroup = new Map<string, number>();
for (const report of reports) {
const identifiers = this.getUsageReportIdentifiers(report);
let groupIndex: number | undefined;
for (const identifier of identifiers) {
const existing = idToGroup.get(identifier);
if (existing !== undefined) {
groupIndex = existing;
break;
}
}
if (groupIndex === undefined) {
groupIndex = groups.length;
groups.push([]);
}
groups[groupIndex].push(report);
for (const identifier of identifiers) {
idToGroup.set(identifier, groupIndex);
}
}
const deduped = groups.map((group) => this.mergeUsageReportGroup(group));
if (deduped.length !== reports.length) {
this.usageLogger?.debug("Usage reports deduped", {
before: reports.length,
after: deduped.length,
});
}
return deduped;
}
private isUsageLimitExhausted(limit: UsageLimit): boolean {
if (limit.status === "exhausted") return true;
const amount = limit.amount;
@@ -883,6 +989,9 @@ export class AuthStorage {
...this.data.keys(),
...DEFAULT_USAGE_PROVIDERS.map((provider) => provider.id),
]);
this.usageLogger?.debug("Usage fetch requested", {
providers: Array.from(providers).sort(),
});
for (const provider of providers) {
const providerImpl = resolver(provider as Provider);
if (!providerImpl) continue;
@@ -911,6 +1020,11 @@ export class AuthStorage {
if (providerImpl.supports && !providerImpl.supports(params)) {
continue;
}
this.usageLogger?.debug("Usage fetch queued", {
provider,
credentialType: "api_key",
baseUrl,
});
tasks.push(
providerImpl
.fetchUsage(params, {
@@ -946,6 +1060,14 @@ export class AuthStorage {
continue;
}
this.usageLogger?.debug("Usage fetch queued", {
provider,
credentialType: usageCredential.type,
baseUrl,
accountId: usageCredential.accountId,
email: usageCredential.email,
});
tasks.push(
providerImpl
.fetchUsage(params, {
@@ -967,7 +1089,25 @@ export class AuthStorage {
if (tasks.length === 0) return [];
const results = await Promise.all(tasks);
return results.filter((report): report is UsageReport => report !== null);
const reports = results.filter((report): report is UsageReport => report !== null);
const deduped = this.dedupeUsageReports(reports);
this.usageLogger?.debug("Usage fetch resolved", {
reports: deduped.map((report) => {
const accountLabel =
this.getUsageReportMetadataValue(report, "email") ??
this.getUsageReportMetadataValue(report, "accountId") ??
this.getUsageReportMetadataValue(report, "account") ??
this.getUsageReportMetadataValue(report, "user") ??
this.getUsageReportMetadataValue(report, "username") ??
this.getUsageReportScopeAccountId(report);
return {
provider: report.provider,
limits: report.limits.length,
account: accountLabel,
};
}),
});
return deduped;
}
/**
@@ -1093,7 +1233,16 @@ export class AuthStorage {
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
if (!result) return undefined;
const updated: OAuthCredential = { type: "oauth", ...result.newCredentials };
const updated: OAuthCredential = {
type: "oauth",
access: result.newCredentials.access,
refresh: result.newCredentials.refresh,
expires: result.newCredentials.expires,
accountId: result.newCredentials.accountId ?? selection.credential.accountId,
email: result.newCredentials.email ?? selection.credential.email,
projectId: result.newCredentials.projectId ?? selection.credential.projectId,
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
};
this.replaceCredentialAt(provider, selection.index, updated);
if (checkUsage) {
@@ -56,7 +56,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
cancelled: false,
...(await sink.dump()),
};
} catch (err) {
} catch (err: unknown) {
// Exception covers NonZeroExitError, AbortError, TimeoutError
if (err instanceof Exception) {
if (err.aborted) {
@@ -24,14 +24,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("pwd", path=str(p))
return p
@_category("Navigation")
def cd(path: str | Path) -> Path:
"""Change directory."""
p = Path(path).expanduser().resolve()
os.chdir(p)
_emit_status("cd", path=str(p))
return p
@_category("Shell")
def env(key: str | None = None, value: str | None = None):
"""Get/set environment variables."""
@@ -684,6 +684,13 @@ function resolveFraction(limit: UsageLimit): number | undefined {
return undefined;
}
function resolveProviderUsageTotal(reports: UsageReport[]): number {
return reports
.flatMap((report) => report.limits)
.map((limit) => resolveFraction(limit) ?? 0)
.reduce((sum, value) => sum + value, 0);
}
function formatLimitTitle(limit: UsageLimit): string {
const tier = limit.scope.tier;
if (tier && !limit.label.toLowerCase().includes(tier.toLowerCase())) {
@@ -841,8 +848,18 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
list.push(report);
grouped.set(report.provider, list);
}
const providerEntries = Array.from(grouped.entries())
.map(([provider, providerReports]) => ({
provider,
providerReports,
totalUsage: resolveProviderUsageTotal(providerReports),
}))
.sort((a, b) => {
if (a.totalUsage !== b.totalUsage) return a.totalUsage - b.totalUsage;
return a.provider.localeCompare(b.provider);
});
for (const [provider, providerReports] of grouped.entries()) {
for (const { provider, providerReports } of providerEntries) {
lines.push("");
const providerName = formatProviderName(provider);
@@ -113,7 +113,6 @@ cols(read("data.tsv"), 0, 2, sep="\t")
- Code executes as IPython cells; users see the full cell output (including rendered figures, tables, etc.)
- Kernel persists for the session by default; per-call mode uses a fresh kernel each call. Use `reset: true` to clear state when session mode is active
- Use `cwd` parameter instead of `os.chdir()` in tool call
- Use `plt.show()` to display figures
- Use `display()` from IPython.display for rich output (HTML, Markdown, images, etc.)
- Output streams in real time, truncated after 50KB
+17 -17
View File
@@ -11,27 +11,27 @@ const THEMES_DIR = join(process.cwd(), "packages/coding-agent/src/modes/interact
const INDEX_FILE = join(THEMES_DIR, "index.ts");
async function main() {
const files = await readdir(THEMES_DIR);
const jsonFiles = files.filter(f => f.endsWith(".json")).sort();
const files = await readdir(THEMES_DIR);
const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
const imports: string[] = [];
const exportEntries: string[] = [];
const imports: string[] = [];
const exportEntries: string[] = [];
for (const file of jsonFiles) {
const name = file.replace(".json", "");
const varName = name.replace(/-/g, "_");
imports.push(`import ${varName} from "./${file}" with { type: "json" };`);
exportEntries.push(` "${name}": ${varName},`);
}
for (const file of jsonFiles) {
const name = file.replace(".json", "");
const varName = name.replace(/-/g, "_");
let content = imports.join("\n");
content += "\n\nexport const defaultThemes = {\n";
content += exportEntries.join("\n");
content += "\n};\n";
imports.push(`import ${varName} from "./${file}" with { type: "json" };`);
exportEntries.push(` "${name}": ${varName},`);
}
await Bun.write(INDEX_FILE, content);
console.log(`Updated ${INDEX_FILE} with ${jsonFiles.length} themes.`);
let content = imports.join("\n");
content += "\n\nexport const defaultThemes = {\n";
content += exportEntries.join("\n");
content += "\n};\n";
await Bun.write(INDEX_FILE, content);
console.log(`Updated ${INDEX_FILE} with ${jsonFiles.length} themes.`);
}
main().catch(console.error);