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:
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user