feat(coding-agent): added calc tool, multi-credential round-robin, auth.json file locking, clickable OAuth URLs

- Added calc tool supporting basic mathematical calculations with arithmetic operators, parentheses, and hex/binary/octal literals.
- Added support for multiple API credentials per provider with round-robin distribution across sessions.
- Added file locking on auth.json to prevent concurrent write corruption.
- Added clickable OAuth login URL display in terminal with plain text fallback.
- Updated ask tool prompt to emphasize exhausting other options before asking users.
- Refactored OAuth credential storage to append new credentials instead of replacing existing ones.
This commit is contained in:
can1357
2026-01-10 03:34:07 +01:00
parent 65b789e1e8
commit 020beb5616
18 changed files with 882 additions and 75 deletions
+4 -1
View File
@@ -1,9 +1,12 @@
# Changelog
## [Unreleased]
### Added
- Added `calc` tool for basic mathematical calculations with support for arithmetic operators, parentheses, and hex/binary/octal literals
- Added support for multiple API credentials per provider with round-robin distribution across sessions
- Added file locking for auth.json to prevent concurrent write corruption
- Added clickable OAuth login URL display in terminal
- Added `workdir` parameter to bash tool to execute commands in a specific directory without requiring `cd` commands
### Changed
+7 -2
View File
@@ -114,10 +114,15 @@ Add API keys to `~/.omp/agent/auth.json`:
```json
{
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
"anthropic": [
{ "type": "api_key", "key": "sk-ant-..." },
{ "type": "api_key", "key": "sk-ant-..." }
],
"openai": { "type": "api_key", "key": "sk-..." },
"google": { "type": "api_key", "key": "..." }
}
If a provider has multiple credentials, new sessions round robin across them and stay sticky per session.
```
**Option 2: Environment variables**
@@ -152,7 +157,7 @@ omp
/login # Select provider, authorize in browser
```
**Note:** `/login` replaces any existing API key for that provider with OAuth credentials in `auth.json`.
**Note:** `/login` replaces any existing API keys for that provider with OAuth credentials in `auth.json`. If OAuth credentials already exist, `/login` appends another entry.
**GitHub Copilot notes:**
@@ -745,7 +745,7 @@ export class AgentSession {
}
// Validate API key
const apiKey = await this._modelRegistry.getApiKey(this.model);
const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
if (!apiKey) {
throw new Error(
`No API key found for ${this.model.provider}.\n\n` +
@@ -1142,7 +1142,7 @@ export class AgentSession {
* @throws Error if no API key available for the model
*/
async setModel(model: Model<any>, role: string = "default"): Promise<void> {
const apiKey = await this._modelRegistry.getApiKey(model);
const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
@@ -1161,7 +1161,7 @@ export class AgentSession {
* @throws Error if no API key available for the model
*/
async setModelTemporary(model: Model<any>): Promise<void> {
const apiKey = await this._modelRegistry.getApiKey(model);
const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}/${model.id}`);
}
@@ -1255,7 +1255,7 @@ export class AgentSession {
const next = this._scopedModels[nextIndex];
// Validate API key
const apiKey = await this._modelRegistry.getApiKey(next.model);
const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
}
@@ -1283,7 +1283,7 @@ export class AgentSession {
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const nextModel = availableModels[nextIndex];
const apiKey = await this._modelRegistry.getApiKey(nextModel);
const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
}
@@ -1413,7 +1413,7 @@ export class AgentSession {
throw new Error("No model selected");
}
const apiKey = await this._modelRegistry.getApiKey(this.model);
const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${this.model.provider}`);
}
@@ -1698,7 +1698,7 @@ export class AgentSession {
let lastError: unknown;
for (const candidate of candidates) {
const apiKey = await this._modelRegistry.getApiKey(candidate);
const apiKey = await this._modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) continue;
let attempt = 0;
@@ -2344,7 +2344,7 @@ export class AgentSession {
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
const model = this.model!;
const apiKey = await this._modelRegistry.getApiKey(model);
const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${model.provider}`);
}
+293 -28
View File
@@ -3,7 +3,17 @@
* Handles loading, saving, and refreshing credentials from auth.json.
*/
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import {
chmodSync,
closeSync,
existsSync,
openSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
import {
getEnvApiKey,
@@ -29,15 +39,26 @@ export type OAuthCredential = {
export type AuthCredential = ApiKeyCredential | OAuthCredential;
export type AuthStorageData = Record<string, AuthCredential>;
export type AuthCredentialEntry = AuthCredential | AuthCredential[];
export type AuthStorageData = Record<string, AuthCredentialEntry>;
/**
* Credential storage backed by a JSON file.
* Reads from multiple fallback paths, writes to primary path.
*/
export class AuthStorage {
// File locking configuration for concurrent access protection
private static readonly lockRetryDelayMs = 50; // Polling interval when waiting for lock
private static readonly lockTimeoutMs = 5000; // Max wait time before failing
private static readonly lockStaleMs = 30000; // Age threshold for auto-removing orphaned locks
private data: AuthStorageData = {};
private runtimeOverrides: Map<string, string> = new Map();
/** Tracks next credential index per provider:type key for round-robin distribution */
private providerRoundRobinIndex: Map<string, number> = new Map();
/** Maps provider:type -> sessionId -> credentialIndex for session-sticky credential assignment */
private sessionCredentialIndexes: Map<string, Map<string, number>> = new Map();
private fallbackResolver?: (provider: string) => string | undefined;
/**
@@ -105,24 +126,244 @@ export class AuthStorage {
* Save credentials to disk.
*/
private async save(): Promise<void> {
writeFileSync(this.authPath, JSON.stringify(this.data, null, 2));
chmodSync(this.authPath, 0o600);
const dir = dirname(this.authPath);
chmodSync(dir, 0o700);
const lockFd = await this.acquireLock();
const tempPath = this.getTempPath();
try {
writeFileSync(tempPath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
renameSync(tempPath, this.authPath);
chmodSync(this.authPath, 0o600);
const dir = dirname(this.authPath);
chmodSync(dir, 0o700);
} finally {
this.safeUnlink(tempPath);
this.releaseLock(lockFd);
}
}
/** Returns the lock file path (auth.json.lock) */
private getLockPath(): string {
return `${this.authPath}.lock`;
}
/** Returns a unique temp file path using pid and timestamp to avoid collisions */
private getTempPath(): string {
return `${this.authPath}.tmp-${process.pid}-${Date.now()}`;
}
/** Checks if lock file is older than lockStaleMs (orphaned by crashed process) */
private isLockStale(lockPath: string): boolean {
try {
const stats = statSync(lockPath);
return Date.now() - stats.mtimeMs > AuthStorage.lockStaleMs;
} catch {
return false;
}
}
/**
* Get credential for a provider.
* Acquires exclusive file lock using O_EXCL atomic create.
* Polls with exponential backoff, removes stale locks from crashed processes.
* @returns File descriptor for the lock (must be passed to releaseLock)
*/
private async acquireLock(): Promise<number> {
const lockPath = this.getLockPath();
const start = Date.now();
const timeoutMs = AuthStorage.lockTimeoutMs;
const retryDelayMs = AuthStorage.lockRetryDelayMs;
while (true) {
try {
// O_EXCL fails if file exists, providing atomic lock acquisition
return openSync(lockPath, "wx", 0o600);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "EEXIST") {
throw err;
}
if (this.isLockStale(lockPath)) {
this.safeUnlink(lockPath);
logger.warn("AuthStorage lock was stale, removing", { path: lockPath });
continue;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for auth lock: ${lockPath}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
}
/** Releases file lock by closing fd and removing lock file */
private releaseLock(lockFd: number): void {
const lockPath = this.getLockPath();
try {
closeSync(lockFd);
} catch (error) {
logger.warn("AuthStorage failed to close lock file", { error: String(error) });
}
this.safeUnlink(lockPath);
}
/** Removes file if it exists, ignoring ENOENT errors */
private safeUnlink(path: string): void {
try {
unlinkSync(path);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
logger.warn("AuthStorage failed to remove file", { path, error: String(error) });
}
}
}
/** Normalizes credential storage format: single credential becomes array of one */
private normalizeCredentialEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
if (!entry) return [];
return Array.isArray(entry) ? entry : [entry];
}
/** Returns all credentials for a provider as an array */
private getCredentialsForProvider(provider: string): AuthCredential[] {
return this.normalizeCredentialEntry(this.data[provider]);
}
/** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
private getProviderTypeKey(provider: string, type: AuthCredential["type"]): string {
return `${provider}:${type}`;
}
/**
* Returns next index in round-robin sequence for load distribution.
* Increments stored counter and wraps at total.
*/
private getNextRoundRobinIndex(providerKey: string, total: number): number {
if (total <= 1) return 0;
const current = this.providerRoundRobinIndex.get(providerKey) ?? -1;
const next = (current + 1) % total;
this.providerRoundRobinIndex.set(providerKey, next);
return next;
}
/**
* Selects credential index with session affinity.
* Sessions reuse their assigned credential; new sessions get next round-robin index.
* This ensures a session always uses the same credential for consistency.
*/
private selectCredentialIndex(providerKey: string, sessionId: string | undefined, total: number): number {
if (total <= 1) return 0;
if (!sessionId) return 0;
const sessionMap = this.sessionCredentialIndexes.get(providerKey);
const existing = sessionMap?.get(sessionId);
if (existing !== undefined && existing < total) {
return existing;
}
// New session: assign next round-robin credential and cache the assignment
const next = this.getNextRoundRobinIndex(providerKey, total);
const updatedSessionMap = sessionMap ?? new Map<string, number>();
updatedSessionMap.set(sessionId, next);
this.sessionCredentialIndexes.set(providerKey, updatedSessionMap);
return next;
}
/**
* Selects a credential of the specified type for a provider.
* Returns both the credential and its index in the original array (for updates/removal).
* Uses session-sticky selection when multiple credentials exist.
*/
private selectCredentialByType<T extends AuthCredential["type"]>(
provider: string,
type: T,
sessionId?: string,
): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
const credentials = this.getCredentialsForProvider(provider)
.map((credential, index) => ({ credential, index }))
.filter(
(entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
entry.credential.type === type,
);
if (credentials.length === 0) return undefined;
if (credentials.length === 1) return credentials[0];
const providerKey = this.getProviderTypeKey(provider, type);
const selectedIndex = this.selectCredentialIndex(providerKey, sessionId, credentials.length);
return credentials[selectedIndex];
}
/**
* Clears round-robin and session assignment state for a provider.
* Called when credentials are added/removed to prevent stale index references.
*/
private resetProviderAssignments(provider: string): void {
for (const key of this.providerRoundRobinIndex.keys()) {
if (key.startsWith(`${provider}:`)) {
this.providerRoundRobinIndex.delete(key);
}
}
for (const key of this.sessionCredentialIndexes.keys()) {
if (key.startsWith(`${provider}:`)) {
this.sessionCredentialIndexes.delete(key);
}
}
}
/** Updates credential at index in-place (used for OAuth token refresh) */
private replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
const entry = this.data[provider];
if (!entry) return;
if (Array.isArray(entry)) {
if (index >= 0 && index < entry.length) {
const updated = [...entry];
updated[index] = credential;
this.data[provider] = updated;
}
return;
}
if (index === 0) {
this.data[provider] = credential;
}
}
/**
* Removes credential at index (used when OAuth refresh fails).
* Cleans up provider entry if last credential removed.
*/
private removeCredentialAt(provider: string, index: number): void {
const entry = this.data[provider];
if (!entry) return;
if (Array.isArray(entry)) {
const updated = entry.filter((_value, idx) => idx !== index);
if (updated.length > 0) {
this.data[provider] = updated;
} else {
delete this.data[provider];
}
} else {
delete this.data[provider];
}
this.resetProviderAssignments(provider);
}
/**
* Get credential for a provider (first entry if multiple).
*/
get(provider: string): AuthCredential | undefined {
return this.data[provider] ?? undefined;
return this.getCredentialsForProvider(provider)[0];
}
/**
* Set credential for a provider.
*/
async set(provider: string, credential: AuthCredential): Promise<void> {
async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
this.data[provider] = credential;
this.resetProviderAssignments(provider);
await this.save();
}
@@ -131,6 +372,7 @@ export class AuthStorage {
*/
async remove(provider: string): Promise<void> {
delete this.data[provider];
this.resetProviderAssignments(provider);
await this.save();
}
@@ -145,7 +387,7 @@ export class AuthStorage {
* Check if credentials exist for a provider in auth.json.
*/
has(provider: string): boolean {
return provider in this.data;
return this.getCredentialsForProvider(provider).length > 0;
}
/**
@@ -154,14 +396,30 @@ export class AuthStorage {
*/
hasAuth(provider: string): boolean {
if (this.runtimeOverrides.has(provider)) return true;
if (this.data[provider]) return true;
if (this.getCredentialsForProvider(provider).length > 0) return true;
if (getEnvApiKey(provider)) return true;
if (this.fallbackResolver?.(provider)) return true;
return false;
}
/**
* Get all credentials (for passing to getOAuthApiKey).
* Check if OAuth credentials are configured for a provider.
*/
hasOAuth(provider: string): boolean {
return this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth");
}
/**
* Get OAuth credentials for a provider.
*/
getOAuthCredential(provider: string): OAuthCredential | undefined {
return this.getCredentialsForProvider(provider).find(
(credential): credential is OAuthCredential => credential.type === "oauth",
);
}
/**
* Get all credentials.
*/
getAll(): AuthStorageData {
return { ...this.data };
@@ -207,7 +465,14 @@ export class AuthStorage {
throw new Error(`Unknown OAuth provider: ${provider}`);
}
await this.set(provider, { type: "oauth", ...credentials });
const newCredential: OAuthCredential = { type: "oauth", ...credentials };
const existing = this.getCredentialsForProvider(provider);
if (existing.length === 0) {
await this.set(provider, newCredential);
return;
}
await this.set(provider, [...existing, newCredential]);
}
/**
@@ -226,37 +491,37 @@ export class AuthStorage {
* 4. Environment variable
* 5. Fallback resolver (models.json custom providers)
*/
async getApiKey(provider: string): Promise<string | undefined> {
async getApiKey(provider: string, sessionId?: string): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(provider);
if (runtimeKey) {
return runtimeKey;
}
const cred = this.data[provider];
if (cred?.type === "api_key") {
return cred.key;
const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
if (apiKeySelection) {
return apiKeySelection.credential.key;
}
if (cred?.type === "oauth") {
// Filter to only oauth credentials for getOAuthApiKey
const oauthCreds: Record<string, OAuthCredentials> = {};
for (const [key, value] of Object.entries(this.data)) {
if (value.type === "oauth") {
oauthCreds[key] = value;
}
}
const oauthSelection = this.selectCredentialByType(provider, "oauth", sessionId);
if (oauthSelection) {
const oauthCreds: Record<string, OAuthCredentials> = {
[provider]: oauthSelection.credential,
};
try {
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
if (result) {
this.data[provider] = { type: "oauth", ...result.newCredentials };
this.replaceCredentialAt(provider, oauthSelection.index, { type: "oauth", ...result.newCredentials });
await this.save();
return result.apiKey;
}
} catch {
await this.remove(provider);
this.removeCredentialAt(provider, oauthSelection.index);
await this.save();
if (this.getCredentialsForProvider(provider).some((credential) => credential.type === "oauth")) {
return this.getApiKey(provider, sessionId);
}
}
}
@@ -187,8 +187,8 @@ export class ModelRegistry {
const combined = [...builtInModels, ...customModels];
// Update github-copilot base URL based on OAuth credentials
const copilotCred = this.authStorage.get("github-copilot");
if (copilotCred?.type === "oauth") {
const copilotCred = this.authStorage.getOAuthCredential("github-copilot");
if (copilotCred) {
const domain = copilotCred.enterpriseUrl
? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined)
: undefined;
@@ -390,22 +390,21 @@ export class ModelRegistry {
/**
* Get API key for a model.
*/
async getApiKey(model: Model<Api>): Promise<string | undefined> {
return this.authStorage.getApiKey(model.provider);
async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
return this.authStorage.getApiKey(model.provider, sessionId);
}
/**
* Get API key for a provider (e.g., "openai").
*/
async getApiKeyForProvider(provider: string): Promise<string | undefined> {
return this.authStorage.getApiKey(provider);
async getApiKeyForProvider(provider: string, sessionId?: string): Promise<string | undefined> {
return this.authStorage.getApiKey(provider, sessionId);
}
/**
* Check if a model is using OAuth credentials (subscription).
*/
isUsingOAuth(model: Model<Api>): boolean {
const cred = this.authStorage.get(model.provider);
return cred?.type === "oauth";
return this.authStorage.hasOAuth(model.provider);
}
}
+5 -4
View File
@@ -538,6 +538,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
time("sessionManager");
const sessionId = sessionManager.getSessionId();
// Check if session has existing data to restore
const existingSession = sessionManager.buildSessionContext();
@@ -554,7 +555,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const parsedModel = parseModelString(defaultModelStr);
if (parsedModel) {
const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
if (restoredModel && (await modelRegistry.getApiKey(restoredModel, sessionId))) {
model = restoredModel;
}
}
@@ -570,7 +571,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const parsedModel = parseModelString(settingsDefaultModel);
if (parsedModel) {
const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
if (settingsModel && (await modelRegistry.getApiKey(settingsModel, sessionId))) {
model = settingsModel;
}
}
@@ -580,7 +581,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Fall back to first available model with a valid API key
if (!model) {
for (const m of modelRegistry.getAll()) {
if (await modelRegistry.getApiKey(m)) {
if (await modelRegistry.getApiKey(m, sessionId)) {
model = m;
break;
}
@@ -921,7 +922,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
const key = await modelRegistry.getApiKey(currentModel, sessionId);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
}
@@ -72,6 +72,7 @@ const toolDescriptions: Record<ToolName, string> = {
ask: "Ask user for input or clarification",
read: "Read file contents",
bash: "Execute bash commands (npm, docker, etc.)",
calc: "{ calculations: array of { expression: string, prefix: string, suffix: string } } Basic calculations.",
ssh: "Execute commands on remote hosts via SSH",
edit: "Make surgical edits to files (find exact text and replace)",
write: "Create or overwrite files",
@@ -68,11 +68,13 @@ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: s
* @param firstMessage The first user message
* @param registry Model registry
* @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
* @param sessionId Optional session id for sticky API key selection
*/
export async function generateSessionTitle(
firstMessage: string,
registry: ModelRegistry,
savedSmolModel?: string,
sessionId?: string,
): Promise<string | null> {
const candidates = getTitleModelCandidates(registry, savedSmolModel);
if (candidates.length === 0) {
@@ -86,7 +88,7 @@ export async function generateSessionTitle(
const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
for (const model of candidates) {
const apiKey = await registry.getApiKey(model);
const apiKey = await registry.getApiKey(model, sessionId);
if (!apiKey) {
logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
continue;
@@ -0,0 +1,500 @@
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme";
import calculatorDescription from "../../prompts/tools/calculator.md" with { type: "text" };
import type { RenderResultOptions } from "../custom-tools/types";
import { untilAborted } from "../utils";
import type { ToolSession } from "./index";
import {
formatCount,
formatEmptyMessage,
formatExpandHint,
formatMeta,
formatMoreItems,
PREVIEW_LIMITS,
truncate,
TRUNCATE_LENGTHS,
} from "./render-utils";
// =============================================================================
// Token Types
// =============================================================================
/** Supported arithmetic operators (** is exponentiation). */
type Operator = "+" | "-" | "*" | "/" | "%" | "**";
/**
* Lexer token variants:
* - number: parsed numeric value with original string for error messages
* - operator: arithmetic operator
* - paren: grouping parenthesis
*/
type Token =
| { type: "number"; value: number; raw: string }
| { type: "operator"; value: Operator }
| { type: "paren"; value: "(" | ")" };
const calculatorSchema = Type.Object({
calculations: Type.Array(
Type.Object({
expression: Type.String({ description: "Math expression to evaluate" }),
prefix: Type.String({ description: "Text to prepend to the result" }),
suffix: Type.String({ description: "Text to append to the result" }),
}),
{ description: "List of calculations to evaluate", minItems: 1 },
),
});
export interface CalculatorToolDetails {
results: Array<{ expression: string; value: number; output: string }>;
}
// =============================================================================
// Character classification helpers for numeric literal parsing
// =============================================================================
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isHexDigit(ch: string): boolean {
return (ch >= "0" && ch <= "9") || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F");
}
function isBinaryDigit(ch: string): boolean {
return ch === "0" || ch === "1";
}
function isOctalDigit(ch: string): boolean {
return ch >= "0" && ch <= "7";
}
// =============================================================================
// Tokenizer
// =============================================================================
/**
* Tokenize a math expression into numbers, operators, and parentheses.
*
* Number formats supported:
* - Decimal: 123, 3.14, .5
* - Scientific: 1e10, 2.5E-3
* - Hexadecimal: 0xFF
* - Binary: 0b1010
* - Octal: 0o755
*/
function tokenizeExpression(expression: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < expression.length) {
const ch = expression[i];
// Skip whitespace
if (ch.trim() === "") {
i += 1;
continue;
}
if (ch === "(" || ch === ")") {
tokens.push({ type: "paren", value: ch });
i += 1;
continue;
}
// Check ** before single * to handle exponentiation
if (ch === "*" && expression[i + 1] === "*") {
tokens.push({ type: "operator", value: "**" });
i += 2;
continue;
}
if (ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%") {
tokens.push({ type: "operator", value: ch });
i += 1;
continue;
}
// Number parsing: starts with digit or decimal point followed by digit
const next = expression[i + 1];
const numberStart = isDigit(ch) || (ch === "." && next !== undefined && isDigit(next));
if (!numberStart) {
throw new Error(`Invalid character "${ch}" in expression`);
}
const start = i;
// Handle prefixed literals (0x, 0b, 0o)
if (ch === "0" && next !== undefined) {
const prefix = next.toLowerCase();
if (prefix === "x" || prefix === "b" || prefix === "o") {
i += 2; // Skip "0x" / "0b" / "0o"
let hasDigit = false;
while (i < expression.length) {
const digit = expression[i];
const valid =
prefix === "x" ? isHexDigit(digit) : prefix === "b" ? isBinaryDigit(digit) : isOctalDigit(digit);
if (!valid) break;
hasDigit = true;
i += 1;
}
if (!hasDigit) {
throw new Error(`Invalid numeric literal starting at "${expression.slice(start, i)}"`);
}
const raw = expression.slice(start, i);
const value = Number(raw); // JS Number() handles 0x/0b/0o natively
if (!Number.isFinite(value)) {
throw new Error(`Invalid number "${raw}"`);
}
tokens.push({ type: "number", value, raw });
continue;
}
}
// Parse decimal number: integer part
let hasDigits = false;
while (i < expression.length && isDigit(expression[i])) {
hasDigits = true;
i += 1;
}
// Fractional part
if (expression[i] === ".") {
i += 1;
while (i < expression.length && isDigit(expression[i])) {
hasDigits = true;
i += 1;
}
}
if (!hasDigits) {
throw new Error(`Invalid number starting at "${expression.slice(start, i + 1)}"`);
}
// Scientific notation exponent (e.g., 1e10, 2.5E-3)
if (expression[i] === "e" || expression[i] === "E") {
i += 1;
if (expression[i] === "+" || expression[i] === "-") {
i += 1;
}
let hasExponentDigits = false;
while (i < expression.length && isDigit(expression[i])) {
hasExponentDigits = true;
i += 1;
}
if (!hasExponentDigits) {
throw new Error(`Invalid exponent in "${expression.slice(start, i)}"`);
}
}
const raw = expression.slice(start, i);
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`Invalid number "${raw}"`);
}
tokens.push({ type: "number", value, raw });
}
return tokens;
}
// =============================================================================
// Recursive Descent Parser
// =============================================================================
/**
* Recursive descent parser for arithmetic expressions.
*
* Operator precedence (lowest to highest):
* 1. Addition, subtraction (+, -)
* 2. Multiplication, division, modulo (*, /, %)
* 3. Unary plus/minus (+x, -x)
* 4. Exponentiation (**)
* 5. Parentheses and literals
*
* Each precedence level has its own parse method. Lower precedence methods
* call higher precedence methods, building the AST implicitly through
* the call stack.
*/
class ExpressionParser {
private index = 0;
constructor(private readonly tokens: Token[]) {}
/** Parse the full expression and ensure all tokens are consumed. */
parse(): number {
const value = this.parseExpression();
if (this.index < this.tokens.length) {
throw new Error("Unexpected token in expression");
}
return value;
}
/**
* Parse addition and subtraction (lowest precedence).
* Left-associative: 1 - 2 - 3 = (1 - 2) - 3
*/
private parseExpression(): number {
let value = this.parseTerm();
while (true) {
if (this.matchOperator("+")) {
value += this.parseTerm();
continue;
}
if (this.matchOperator("-")) {
value -= this.parseTerm();
continue;
}
break;
}
return value;
}
/**
* Parse multiplication, division, and modulo.
* Left-associative: 8 / 4 / 2 = (8 / 4) / 2
*/
private parseTerm(): number {
let value = this.parseUnary();
while (true) {
if (this.matchOperator("*")) {
value *= this.parseUnary();
continue;
}
if (this.matchOperator("/")) {
value /= this.parseUnary();
continue;
}
if (this.matchOperator("%")) {
value %= this.parseUnary();
continue;
}
break;
}
return value;
}
/**
* Parse unary + and - operators.
* Recursive to handle chained unary: --x, +-x
*/
private parseUnary(): number {
if (this.matchOperator("+")) {
return this.parseUnary();
}
if (this.matchOperator("-")) {
return -this.parseUnary();
}
return this.parsePower();
}
/**
* Parse exponentiation operator.
* Right-associative: 2 ** 3 ** 2 = 2 ** (3 ** 2) = 512
* Achieved by recursive call to parsePower for the right operand.
*/
private parsePower(): number {
let value = this.parsePrimary();
if (this.matchOperator("**")) {
value = value ** this.parsePower(); // Right-associative via recursion
}
return value;
}
/**
* Parse primary expressions: number literals and parenthesized subexpressions.
* Parentheses restart parsing at lowest precedence (parseExpression).
*/
private parsePrimary(): number {
const token = this.peek();
if (!token) {
throw new Error("Unexpected end of expression");
}
if (token.type === "number") {
this.index += 1;
return token.value;
}
if (token.type === "paren" && token.value === "(") {
this.index += 1;
const value = this.parseExpression(); // Reset to lowest precedence
if (!this.matchParen(")")) {
throw new Error("Missing closing parenthesis");
}
return value;
}
throw new Error("Unexpected token in expression");
}
/** Consume operator if it matches, advancing the token index. */
private matchOperator(value: Operator): boolean {
const token = this.tokens[this.index];
if (token && token.type === "operator" && token.value === value) {
this.index += 1;
return true;
}
return false;
}
/** Consume parenthesis if it matches, advancing the token index. */
private matchParen(value: "(" | ")"): boolean {
const token = this.tokens[this.index];
if (token && token.type === "paren" && token.value === value) {
this.index += 1;
return true;
}
return false;
}
/** Look at current token without consuming it. */
private peek(): Token | undefined {
return this.tokens[this.index];
}
}
// =============================================================================
// Expression Evaluator
// =============================================================================
/**
* Evaluate a math expression string and return the numeric result.
*
* Pipeline: expression string -> tokens -> parse tree (implicit) -> value
*
* @throws Error on syntax errors, empty expressions, or non-finite results (Infinity, NaN)
*/
function evaluateExpression(expression: string): number {
const tokens = tokenizeExpression(expression);
if (tokens.length === 0) {
throw new Error("Expression is empty");
}
const parser = new ExpressionParser(tokens);
const value = parser.parse();
if (!Number.isFinite(value)) {
throw new Error("Expression result is not a finite number");
}
// Normalize -0 to 0 for consistent output
return Object.is(value, -0) ? 0 : value;
}
function formatResult(value: number): string {
return String(value);
}
export function createCalculatorTool(_session: ToolSession): AgentTool<typeof calculatorSchema> {
return {
name: "calc",
label: "Calc",
description: calculatorDescription,
parameters: calculatorSchema,
execute: async (
_toolCallId: string,
{ calculations }: { calculations: Array<{ expression: string; prefix: string; suffix: string }> },
signal?: AbortSignal,
) => {
return untilAborted(signal, async () => {
const results = calculations.map((calc) => {
const value = evaluateExpression(calc.expression);
const output = `${calc.prefix}${formatResult(value)}${calc.suffix}`;
return { expression: calc.expression, value, output };
});
const outputText = results.map((result) => result.output).join("\n");
return {
content: [{ type: "text", text: outputText }],
details: { results },
};
});
},
};
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface CalculatorRenderArgs {
calculations?: Array<{ expression: string; prefix?: string; suffix?: string }>;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
/**
* TUI renderer for calculator tool calls and results.
* Handles both collapsed (preview) and expanded (full) display modes.
*/
export const calculatorToolRenderer = {
/**
* Render the tool call header showing the first expression and count.
* Format: "Calc <expression> (N calcs)"
*/
renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component {
const label = uiTheme.fg("toolTitle", uiTheme.bold("Calc"));
const count = args.calculations?.length ?? 0;
const firstExpression = args.calculations?.[0]?.expression;
let text = label;
if (firstExpression) {
text += ` ${uiTheme.fg("accent", truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "..."))}`;
}
const meta: string[] = [];
if (count > 0) meta.push(formatCount("calc", count));
text += formatMeta(meta, uiTheme);
return new Text(text, 0, 0);
},
/**
* Render calculation results as a tree list.
* Collapsed mode shows first N items with expand hint; expanded shows all.
*/
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails },
{ expanded }: RenderResultOptions,
uiTheme: Theme,
): Component {
const details = result.details;
const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
// Prefer structured details; fall back to parsing text content
let outputs = details?.results?.map((entry) => entry.output) ?? [];
if (outputs.length === 0 && textContent.trim()) {
outputs = textContent.split("\n").filter((line) => line.trim().length > 0);
}
if (outputs.length === 0) {
return new Text(formatEmptyMessage("No results", uiTheme), 0, 0);
}
// Limit visible items in collapsed mode
const maxItems = expanded ? outputs.length : Math.min(outputs.length, COLLAPSED_LIST_LIMIT);
const hasMore = outputs.length > maxItems;
const icon = uiTheme.styledSymbol("status.success", "success");
const summary = uiTheme.fg("dim", formatCount("result", outputs.length));
const expandHint = formatExpandHint(expanded, hasMore, uiTheme);
let text = `${icon} ${summary}${expandHint}`;
// Render each result as a tree branch
for (let i = 0; i < maxItems; i += 1) {
const isLast = i === maxItems - 1 && !hasMore;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", outputs[i])}`;
}
// Show overflow indicator for collapsed mode
if (hasMore) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
"muted",
formatMoreItems(outputs.length - maxItems, "result", uiTheme),
)}`;
}
return new Text(text, 0, 0);
},
};
@@ -20,6 +20,7 @@ describe("createTools", () => {
// Core tools should always be present
expect(names).toContain("bash");
expect(names).toContain("calc");
expect(names).toContain("read");
expect(names).toContain("edit");
expect(names).toContain("write");
@@ -162,6 +163,7 @@ describe("createTools", () => {
const expectedTools = [
"ask",
"bash",
"calc",
"ssh",
"edit",
"find",
@@ -1,5 +1,6 @@
export { type AskToolDetails, askTool, createAskTool } from "./ask";
export { type BashToolDetails, createBashTool } from "./bash";
export { createCalculatorTool, type CalculatorToolDetails } from "./calculator";
export { createCompleteTool } from "./complete";
export { createEditTool } from "./edit";
// Exa MCP tools (22 tools)
@@ -57,6 +58,7 @@ import type { EventBus } from "../event-bus";
import type { BashInterceptorRule } from "../settings-manager";
import { createAskTool } from "./ask";
import { createBashTool } from "./bash";
import { createCalculatorTool } from "./calculator";
import { createCompleteTool } from "./complete";
import { createEditTool } from "./edit";
import { createFindTool } from "./find";
@@ -119,6 +121,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
ask: createAskTool,
bash: createBashTool,
calc: createCalculatorTool,
ssh: createSshTool,
edit: createEditTool,
find: createFindTool,
@@ -9,6 +9,7 @@ import type { Theme } from "../../modes/interactive/theme/theme";
import type { RenderResultOptions } from "../custom-tools/types";
import { askToolRenderer } from "./ask";
import { bashToolRenderer } from "./bash";
import { calculatorToolRenderer } from "./calculator";
import { editToolRenderer } from "./edit";
import { findToolRenderer } from "./find";
import { grepToolRenderer } from "./grep";
@@ -37,6 +38,7 @@ type ToolRenderer = {
export const toolRenderers: Record<string, ToolRenderer> = {
ask: askToolRenderer as ToolRenderer,
bash: bashToolRenderer as ToolRenderer,
calc: calculatorToolRenderer as ToolRenderer,
edit: editToolRenderer as ToolRenderer,
find: findToolRenderer as ToolRenderer,
grep: grepToolRenderer as ToolRenderer,
@@ -11,7 +11,7 @@
import * as os from "node:os";
import * as path from "node:path";
import { getConfigDirPaths } from "../../../config";
import type { AnthropicAuthConfig, AuthJson, ModelsJson } from "./types";
import type { AnthropicAuthConfig, AnthropicOAuthCredential, AuthJson, ModelsJson } from "./types";
const DEFAULT_BASE_URL = "https://api.anthropic.com";
@@ -76,6 +76,11 @@ export function isOAuthToken(apiKey: string): boolean {
return apiKey.includes("sk-ant-oat");
}
function normalizeAnthropicOAuthCredentials(entry: AuthJson["anthropic"] | undefined): AnthropicOAuthCredential[] {
if (!entry) return [];
return Array.isArray(entry) ? entry : [entry];
}
/**
* Find Anthropic auth config using 4-tier priority:
* 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL
@@ -126,13 +131,16 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
}
// 3. OAuth credentials in auth.json (with 5-minute expiry buffer, check all config dirs)
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
for (const configDir of configDirs) {
const authJson = await readJson<AuthJson>(path.join(configDir, "auth.json"));
if (authJson?.anthropic?.type === "oauth" && authJson.anthropic.access) {
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
if (authJson.anthropic.expires > Date.now() + expiryBuffer) {
const credentials = normalizeAnthropicOAuthCredentials(authJson?.anthropic);
for (const credential of credentials) {
if (credential.type !== "oauth" || !credential.access) continue;
if (credential.expires > now + expiryBuffer) {
return {
apiKey: authJson.anthropic.access,
apiKey: credential.access,
baseUrl: DEFAULT_BASE_URL,
isOAuth: true,
};
@@ -90,14 +90,18 @@ export interface ModelsJson {
}
/** auth.json structure for OAuth credentials */
export interface AnthropicOAuthCredential {
type: "oauth";
access: string;
refresh?: string;
/** Expiry timestamp in milliseconds */
expires: number;
}
export type AnthropicAuthJsonEntry = AnthropicOAuthCredential | AnthropicOAuthCredential[];
export interface AuthJson {
anthropic?: {
type: "oauth";
access: string;
refresh?: string;
/** Expiry timestamp in milliseconds */
expires: number;
};
anthropic?: AnthropicAuthJsonEntry;
}
/** Anthropic API response types */
@@ -70,8 +70,7 @@ export class OAuthSelectorComponent extends Container {
const isAvailable = provider.available;
// Check if user is logged in for this provider
const credentials = this.authStorage.get(provider.id);
const isLoggedIn = credentials?.type === "oauth";
const isLoggedIn = this.authStorage.hasOAuth(provider.id);
const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
let line = "";
@@ -1157,7 +1157,7 @@ export class InteractiveMode {
if (!hasUserMessages && !this.sessionManager.getSessionTitle()) {
const registry = this.session.modelRegistry;
const smolModel = this.settingsManager.getModelRole("smol");
generateSessionTitle(text, registry, smolModel)
generateSessionTitle(text, registry, smolModel, this.session.sessionId)
.then(async (title) => {
if (title) {
await this.sessionManager.setSessionTitle(title);
@@ -2613,9 +2613,7 @@ export class InteractiveMode {
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "logout") {
const providers = this.session.modelRegistry.authStorage.list();
const loggedInProviders = providers.filter(
(p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth",
);
const loggedInProviders = providers.filter((p) => this.session.modelRegistry.authStorage.hasOAuth(p));
if (loggedInProviders.length === 0) {
this.showStatus("No OAuth providers logged in. Use /login first.");
return;
@@ -2636,6 +2634,7 @@ export class InteractiveMode {
await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
onAuth: (info: { url: string; instructions?: string }) => {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
// Use OSC 8 hyperlink escape sequence for clickable link
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
+11 -5
View File
@@ -8,11 +8,6 @@ Use this tool to:
- Request user preferences (styling, naming conventions, architecture patterns)
- Offer meaningful choices about task direction
Do NOT use for:
- Questions resolvable by reading files or docs
- Permission for normal dev tasks (just proceed)
- Decisions you should make from codebase context
Tips:
- Place recommended option first with " (Recommended)" suffix
- 2-5 concise, distinct options
@@ -22,3 +17,14 @@ Tips:
question: "Which authentication method should this API use?"
options: [{"label": "JWT (Recommended)"}, {"label": "OAuth2"}, {"label": "Session cookies"}]
</example>
## Critical: Resolve before asking
**Exhaust all other options before asking.** Questions interrupt user flow.
1. **Unknown file location?** → Search with grep/find first. Only ask if search fails.
2. **Ambiguous syntax/format?** → Infer from context and codebase conventions. Make a reasonable choice.
3. **Missing details?** → Check docs, related files, commit history. Fill gaps yourself.
4. **Implementation approach?** → Choose based on codebase patterns. Ask only for genuinely novel architectural decisions.
If you can make a reasonable inference from the user's request, **do it**. Users communicate intent, not specifications—your job is to translate intent into correct implementation.
@@ -0,0 +1,8 @@
Basic calculations.
Input:
- calculations: array of { expression: string, prefix: string, suffix: string }
Notes:
- Supports +, -, *, /, %, ** and parentheses.
- Supports decimal, hex (0x), binary (0b), and octal (0o) literals.