Merge pull request #2302 from thedotmack/codex/remove-agent-pool-timeout
[codex] Remove agent pool timeout data loss
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+201
-206
File diff suppressed because one or more lines are too long
+221
-17
@@ -7,6 +7,7 @@ import { homedir } from 'os';
|
|||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
|
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
|
||||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||||
|
import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js';
|
||||||
import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js';
|
import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js';
|
||||||
import {
|
import {
|
||||||
ensureBun,
|
ensureBun,
|
||||||
@@ -588,13 +589,143 @@ function mergeSettings(updates: Record<string, string>): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProviderId = 'claude' | 'gemini' | 'openrouter';
|
type ProviderId = 'claude' | 'gemini' | 'openrouter';
|
||||||
|
type ClaudeAccessMode = 'subscription' | 'api-key';
|
||||||
|
type ClaudeApiMode = 'direct' | 'gateway';
|
||||||
|
|
||||||
|
function readRawStoredAuthMethod(): 'subscription' | 'api-key' | 'gateway' | undefined {
|
||||||
|
try {
|
||||||
|
if (!existsSync(USER_SETTINGS_PATH)) return undefined;
|
||||||
|
const raw = JSON.parse(readFileSync(USER_SETTINGS_PATH, 'utf-8')) as Record<string, unknown>;
|
||||||
|
const flat = (raw.env && typeof raw.env === 'object' ? raw.env : raw) as Record<string, unknown>;
|
||||||
|
const value = flat.CLAUDE_MEM_CLAUDE_AUTH_METHOD;
|
||||||
|
if (value === 'subscription' || value === 'api-key' || value === 'gateway') return value;
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClaudeAuthMethod(): 'subscription' | 'api-key' | 'gateway' {
|
||||||
|
const stored = readRawStoredAuthMethod();
|
||||||
|
if (stored) return stored;
|
||||||
|
const env = loadClaudeMemEnv();
|
||||||
|
if (env.ANTHROPIC_BASE_URL?.trim()) return 'gateway';
|
||||||
|
if (env.ANTHROPIC_API_KEY?.trim()) return 'api-key';
|
||||||
|
return 'subscription';
|
||||||
|
}
|
||||||
|
|
||||||
async function promptProvider(options: InstallOptions): Promise<ProviderId> {
|
async function promptProvider(options: InstallOptions): Promise<ProviderId> {
|
||||||
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
|
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
|
||||||
|
|
||||||
const persistClaudeProvider = () => {
|
const persistClaudeProvider = (authMethod?: 'subscription' | 'api-key' | 'gateway') => {
|
||||||
const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude' });
|
const resolvedAuthMethod = authMethod ?? resolveClaudeAuthMethod();
|
||||||
if (wrote) log.info('Saved provider=claude to ~/.claude-mem/settings.json');
|
const wrote = mergeSettings({
|
||||||
|
CLAUDE_MEM_PROVIDER: 'claude',
|
||||||
|
CLAUDE_MEM_CLAUDE_AUTH_METHOD: resolvedAuthMethod,
|
||||||
|
});
|
||||||
|
if (wrote) log.info('Saved Claude Agent SDK configuration to ~/.claude-mem/settings.json');
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSubscriptionAuth = () => {
|
||||||
|
persistClaudeProvider('subscription');
|
||||||
|
saveClaudeMemEnv({
|
||||||
|
ANTHROPIC_API_KEY: '',
|
||||||
|
ANTHROPIC_BASE_URL: '',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: '',
|
||||||
|
});
|
||||||
|
log.info('Configured claude-mem to use your logged-in Claude SDK account.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const configureDirectApiKey = async (): Promise<void> => {
|
||||||
|
const existing = loadClaudeMemEnv().ANTHROPIC_API_KEY || '';
|
||||||
|
if (existing.trim().length > 0) {
|
||||||
|
const choice = await p.select<'keep' | 'replace'>({
|
||||||
|
message: 'An Anthropic API key is already configured. Keep it or enter a new one?',
|
||||||
|
options: [
|
||||||
|
{ value: 'keep', label: 'Keep existing key' },
|
||||||
|
{ value: 'replace', label: 'Enter a new key (rotate)' },
|
||||||
|
],
|
||||||
|
initialValue: 'keep',
|
||||||
|
});
|
||||||
|
if (p.isCancel(choice)) {
|
||||||
|
log.warn('API key prompt cancelled — leaving existing configuration untouched.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === 'keep') {
|
||||||
|
saveClaudeMemEnv({
|
||||||
|
ANTHROPIC_API_KEY: existing.trim(),
|
||||||
|
ANTHROPIC_BASE_URL: '',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: '',
|
||||||
|
});
|
||||||
|
persistClaudeProvider('api-key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyResult = await p.password({
|
||||||
|
message: 'Paste your Anthropic API key:',
|
||||||
|
mask: '*',
|
||||||
|
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(apiKeyResult)) {
|
||||||
|
log.warn('API key prompt cancelled — leaving existing configuration untouched.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveClaudeMemEnv({
|
||||||
|
ANTHROPIC_API_KEY: String(apiKeyResult).trim(),
|
||||||
|
ANTHROPIC_BASE_URL: '',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: '',
|
||||||
|
});
|
||||||
|
persistClaudeProvider('api-key');
|
||||||
|
log.info('Saved Anthropic API key for the Claude Agent SDK path.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const configureGateway = async (): Promise<void> => {
|
||||||
|
const existing = loadClaudeMemEnv();
|
||||||
|
const baseUrlResult = await p.text({
|
||||||
|
message: 'Gateway URL:',
|
||||||
|
placeholder: existing.ANTHROPIC_BASE_URL || 'http://localhost:4000',
|
||||||
|
defaultValue: existing.ANTHROPIC_BASE_URL || '',
|
||||||
|
validate: (v?: string) => {
|
||||||
|
const value = v?.trim() ?? '';
|
||||||
|
if (!value) return 'Gateway URL required';
|
||||||
|
try {
|
||||||
|
new URL(value);
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return 'Enter a valid URL, for example http://localhost:4000';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(baseUrlResult)) {
|
||||||
|
log.warn('Gateway setup cancelled — leaving existing configuration untouched.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await p.password({
|
||||||
|
message: 'Gateway key/token (leave blank to keep current token, or type a new one):',
|
||||||
|
mask: '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenCancelled = p.isCancel(tokenResult);
|
||||||
|
const tokenInput = tokenCancelled ? '' : String(tokenResult).trim();
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
ANTHROPIC_API_KEY: '',
|
||||||
|
ANTHROPIC_BASE_URL: String(baseUrlResult).trim(),
|
||||||
|
};
|
||||||
|
if (!tokenCancelled && tokenInput.length > 0) {
|
||||||
|
env.ANTHROPIC_AUTH_TOKEN = tokenInput;
|
||||||
|
}
|
||||||
|
saveClaudeMemEnv(env);
|
||||||
|
persistClaudeProvider('gateway');
|
||||||
|
if (tokenCancelled || tokenInput.length === 0) {
|
||||||
|
log.info('Gateway URL saved; existing gateway token preserved.');
|
||||||
|
} else {
|
||||||
|
log.info('Configured Claude Agent SDK gateway in ~/.claude-mem/.env.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isInteractive) {
|
if (!isInteractive) {
|
||||||
@@ -611,29 +742,72 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
|
|||||||
return initialProvider;
|
return initialProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedProvider: ProviderId;
|
const runClaudeAuthFlow = async (): Promise<void> => {
|
||||||
if (options.provider) {
|
const resolvedAuthMethod = resolveClaudeAuthMethod();
|
||||||
selectedProvider = options.provider;
|
const initialAccessMode: ClaudeAccessMode =
|
||||||
} else {
|
resolvedAuthMethod === 'subscription' ? 'subscription' : 'api-key';
|
||||||
const result = await p.select<ProviderId>({
|
|
||||||
message: 'Which LLM provider should claude-mem use to compress observations?',
|
const result = await p.select<ClaudeAccessMode>({
|
||||||
|
message: 'Do you use a subscription plan or an API key/gateway for the memory agent?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'claude', label: 'Claude Code auth (default — no extra setup, uses your existing Claude Code subscription)' },
|
{ value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' },
|
||||||
{ value: 'gemini', label: 'Gemini API key (free tier available — fast and cheap)' },
|
{ value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' },
|
||||||
{ value: 'openrouter', label: 'OpenRouter API key (BYO model — wide selection of frontier and open models)' },
|
|
||||||
],
|
],
|
||||||
initialValue: initialProvider,
|
initialValue: initialAccessMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(result)) {
|
if (p.isCancel(result)) {
|
||||||
p.cancel('Installation cancelled.');
|
p.cancel('Installation cancelled.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
selectedProvider = result as ProviderId;
|
if (result === 'subscription') {
|
||||||
|
useSubscriptionAuth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiModeResult = await p.select<ClaudeApiMode>({
|
||||||
|
message: 'How should claude-mem connect?',
|
||||||
|
options: [
|
||||||
|
{ value: 'direct', label: 'Anthropic API key' },
|
||||||
|
{ value: 'gateway', label: 'LiteLLM or custom gateway' },
|
||||||
|
],
|
||||||
|
initialValue: resolvedAuthMethod === 'gateway' || loadClaudeMemEnv().ANTHROPIC_BASE_URL ? 'gateway' : 'direct',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(apiModeResult)) {
|
||||||
|
p.cancel('Installation cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiModeResult === 'gateway') {
|
||||||
|
await configureGateway();
|
||||||
|
} else {
|
||||||
|
await configureDirectApiKey();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let selectedProvider: ProviderId;
|
||||||
|
if (options.provider) {
|
||||||
|
selectedProvider = options.provider;
|
||||||
|
} else {
|
||||||
|
const providerResult = await p.select<ProviderId>({
|
||||||
|
message: 'Which memory provider do you want to use?',
|
||||||
|
options: [
|
||||||
|
{ value: 'claude', label: 'Claude Agent SDK (recommended)' },
|
||||||
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
|
{ value: 'openrouter', label: 'OpenRouter' },
|
||||||
|
],
|
||||||
|
initialValue: initialProvider,
|
||||||
|
});
|
||||||
|
if (p.isCancel(providerResult)) {
|
||||||
|
p.cancel('Installation cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
selectedProvider = providerResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedProvider === 'claude') {
|
if (selectedProvider === 'claude') {
|
||||||
persistClaudeProvider();
|
await runClaudeAuthFlow();
|
||||||
return 'claude';
|
return 'claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,7 +826,7 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
|
|||||||
const apiKeyResult = await p.password({
|
const apiKeyResult = await p.password({
|
||||||
message: `Paste your ${providerLabel} API key:`,
|
message: `Paste your ${providerLabel} API key:`,
|
||||||
mask: '*',
|
mask: '*',
|
||||||
validate: (v: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
|
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(apiKeyResult)) {
|
if (p.isCancel(apiKeyResult)) {
|
||||||
@@ -678,8 +852,9 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
|
|||||||
'claude-sonnet-4-6',
|
'claude-sonnet-4-6',
|
||||||
'claude-opus-4-7',
|
'claude-opus-4-7',
|
||||||
]);
|
]);
|
||||||
|
const allowCustomModel = resolveClaudeAuthMethod() === 'gateway';
|
||||||
|
|
||||||
if (options.model) {
|
if (options.model && !allowCustomModel) {
|
||||||
if (!allowed.has(options.model)) {
|
if (!allowed.has(options.model)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
|
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
|
||||||
@@ -691,10 +866,39 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (options.model && allowCustomModel) {
|
||||||
|
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model });
|
||||||
|
if (wrote) {
|
||||||
|
log.info(`Saved gateway model=${options.model} to ~/.claude-mem/settings.json`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isInteractive) return;
|
if (!isInteractive) return;
|
||||||
|
|
||||||
const initialModel = getSetting('CLAUDE_MEM_MODEL');
|
const initialModel = getSetting('CLAUDE_MEM_MODEL');
|
||||||
|
|
||||||
|
if (allowCustomModel) {
|
||||||
|
const result = await p.text({
|
||||||
|
message: 'Which model should the gateway use?',
|
||||||
|
placeholder: 'claude-haiku-4-5-20251001',
|
||||||
|
defaultValue: initialModel || 'claude-haiku-4-5-20251001',
|
||||||
|
validate: (v?: string) => (!v || v.trim().length === 0) ? 'Model required' : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(result)) {
|
||||||
|
p.cancel('Installation cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModel = String(result).trim();
|
||||||
|
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel });
|
||||||
|
if (wrote) {
|
||||||
|
log.info(`Saved gateway model=${selectedModel} to ~/.claude-mem/settings.json`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001';
|
const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001';
|
||||||
|
|
||||||
const result = await p.select<string>({
|
const result = await p.select<string>({
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class ClaudeProvider {
|
|||||||
|
|
||||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||||
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
|
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
|
||||||
await waitForSlot(maxConcurrent, 60_000);
|
await waitForSlot(maxConcurrent, session.abortController.signal);
|
||||||
|
|
||||||
const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth());
|
const isolatedEnv = sanitizeEnv(await buildIsolatedEnvWithFreshOAuth());
|
||||||
const authMethod = getAuthMethodDescription();
|
const authMethod = getAuthMethodDescription();
|
||||||
|
|||||||
@@ -147,16 +147,16 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
|
|
||||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||||
try {
|
try {
|
||||||
const cleared = pendingStore.clearPendingForSession(session.sessionDbId);
|
const reset = pendingStore.resetProcessingToPending(session.sessionDbId);
|
||||||
if (cleared > 0) {
|
if (reset > 0) {
|
||||||
logger.error('SESSION', `Cleared pending messages after generator error`, {
|
logger.warn('SESSION', `Reset processing messages after generator error`, {
|
||||||
sessionId: session.sessionDbId,
|
sessionId: session.sessionDbId,
|
||||||
cleared
|
reset
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
const normalizedDbError = dbError instanceof Error ? dbError : new Error(String(dbError));
|
const normalizedDbError = dbError instanceof Error ? dbError : new Error(String(dbError));
|
||||||
logger.error('HTTP', 'Failed to clear pending messages', {
|
logger.error('HTTP', 'Failed to reset processing messages after generator error', {
|
||||||
sessionId: session.sessionDbId
|
sessionId: session.sessionDbId
|
||||||
}, normalizedDbError);
|
}, normalizedDbError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export class SettingsRoutes extends BaseRouteHandler {
|
|||||||
'CLAUDE_MEM_WORKER_PORT',
|
'CLAUDE_MEM_WORKER_PORT',
|
||||||
'CLAUDE_MEM_WORKER_HOST',
|
'CLAUDE_MEM_WORKER_HOST',
|
||||||
'CLAUDE_MEM_PROVIDER',
|
'CLAUDE_MEM_PROVIDER',
|
||||||
|
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
|
||||||
'CLAUDE_MEM_GEMINI_API_KEY',
|
'CLAUDE_MEM_GEMINI_API_KEY',
|
||||||
'CLAUDE_MEM_GEMINI_MODEL',
|
'CLAUDE_MEM_GEMINI_MODEL',
|
||||||
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
|
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
|
||||||
@@ -194,6 +195,13 @@ export class SettingsRoutes extends BaseRouteHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD) {
|
||||||
|
const validClaudeAuthMethods = ['subscription', 'api-key', 'gateway', 'cli'];
|
||||||
|
if (!validClaudeAuthMethods.includes(settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD)) {
|
||||||
|
return { valid: false, error: 'CLAUDE_MEM_CLAUDE_AUTH_METHOD must be "subscription", "api-key", "gateway", or "cli"' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.CLAUDE_MEM_GEMINI_MODEL) {
|
if (settings.CLAUDE_MEM_GEMINI_MODEL) {
|
||||||
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
|
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
|
||||||
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
|
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export const ENV_FILE_PATH = paths.envFile();
|
|||||||
|
|
||||||
const BLOCKED_ENV_VARS = [
|
const BLOCKED_ENV_VARS = [
|
||||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||||
|
'ANTHROPIC_AUTH_TOKEN', // Same leak risk as ANTHROPIC_API_KEY; a token inherited from the
|
||||||
|
// shell would otherwise short-circuit OAuth lookup at spawn time.
|
||||||
|
// The fresh token from ~/.claude-mem/.env is re-injected below
|
||||||
|
// when explicit gateway credentials are configured.
|
||||||
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
|
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
|
||||||
'CLAUDE_CODE_OAUTH_TOKEN', // Issue #2215: prevent stale parent-process token from leaking into
|
'CLAUDE_CODE_OAUTH_TOKEN', // Issue #2215: prevent stale parent-process token from leaking into
|
||||||
// isolated env. The fresh token is read from the keychain at spawn
|
// isolated env. The fresh token is read from the keychain at spawn
|
||||||
@@ -22,6 +26,7 @@ const BLOCKED_ENV_VARS = [
|
|||||||
export interface ClaudeMemEnv {
|
export interface ClaudeMemEnv {
|
||||||
ANTHROPIC_API_KEY?: string;
|
ANTHROPIC_API_KEY?: string;
|
||||||
ANTHROPIC_BASE_URL?: string;
|
ANTHROPIC_BASE_URL?: string;
|
||||||
|
ANTHROPIC_AUTH_TOKEN?: string;
|
||||||
GEMINI_API_KEY?: string;
|
GEMINI_API_KEY?: string;
|
||||||
OPENROUTER_API_KEY?: string;
|
OPENROUTER_API_KEY?: string;
|
||||||
}
|
}
|
||||||
@@ -56,7 +61,7 @@ function parseEnvFile(content: string): Record<string, string> {
|
|||||||
function serializeEnvFile(env: Record<string, string>): string {
|
function serializeEnvFile(env: Record<string, string>): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'# claude-mem credentials',
|
'# claude-mem credentials',
|
||||||
'# This file stores API keys for claude-mem memory agent',
|
'# This file stores keys and gateway settings for the claude-mem memory agent',
|
||||||
'# Edit this file or use claude-mem settings to configure',
|
'# Edit this file or use claude-mem settings to configure',
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
@@ -83,6 +88,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
|
|||||||
const result: ClaudeMemEnv = {};
|
const result: ClaudeMemEnv = {};
|
||||||
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
|
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
|
||||||
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
|
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
|
||||||
|
if (parsed.ANTHROPIC_AUTH_TOKEN) result.ANTHROPIC_AUTH_TOKEN = parsed.ANTHROPIC_AUTH_TOKEN;
|
||||||
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
|
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
|
||||||
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
|
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
@@ -126,6 +132,13 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
|||||||
delete updated.ANTHROPIC_BASE_URL;
|
delete updated.ANTHROPIC_BASE_URL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (env.ANTHROPIC_AUTH_TOKEN !== undefined) {
|
||||||
|
if (env.ANTHROPIC_AUTH_TOKEN) {
|
||||||
|
updated.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
} else {
|
||||||
|
delete updated.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (env.GEMINI_API_KEY !== undefined) {
|
if (env.GEMINI_API_KEY !== undefined) {
|
||||||
if (env.GEMINI_API_KEY) {
|
if (env.GEMINI_API_KEY) {
|
||||||
updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
|
updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
|
||||||
@@ -171,6 +184,9 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
|
|||||||
if (credentials.ANTHROPIC_BASE_URL) {
|
if (credentials.ANTHROPIC_BASE_URL) {
|
||||||
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
|
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
|
||||||
}
|
}
|
||||||
|
if (credentials.ANTHROPIC_AUTH_TOKEN) {
|
||||||
|
isolatedEnv.ANTHROPIC_AUTH_TOKEN = credentials.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
if (credentials.GEMINI_API_KEY) {
|
if (credentials.GEMINI_API_KEY) {
|
||||||
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
|
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
|
||||||
}
|
}
|
||||||
@@ -214,10 +230,15 @@ export async function buildIsolatedEnvWithFreshOAuth(
|
|||||||
|
|
||||||
if (!includeCredentials) return isolatedEnv;
|
if (!includeCredentials) return isolatedEnv;
|
||||||
|
|
||||||
// If the user already configured an ANTHROPIC_API_KEY in ~/.claude-mem/.env,
|
// If the user already configured explicit Anthropic/gateway credentials in
|
||||||
// honor that and skip OAuth lookup entirely. API key auth is preferred when
|
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely. A bare
|
||||||
// explicitly configured because it's stateless and stable.
|
// ANTHROPIC_BASE_URL counts because gateways may be tokenless, and falling
|
||||||
if (isolatedEnv.ANTHROPIC_API_KEY) {
|
// back to OAuth would silently route requests to api.anthropic.com.
|
||||||
|
if (
|
||||||
|
isolatedEnv.ANTHROPIC_API_KEY ||
|
||||||
|
isolatedEnv.ANTHROPIC_BASE_URL ||
|
||||||
|
isolatedEnv.ANTHROPIC_AUTH_TOKEN
|
||||||
|
) {
|
||||||
clearStaleMarker();
|
clearStaleMarker();
|
||||||
return isolatedEnv;
|
return isolatedEnv;
|
||||||
}
|
}
|
||||||
@@ -276,10 +297,18 @@ export function hasAnthropicApiKey(): boolean {
|
|||||||
return !!env.ANTHROPIC_API_KEY;
|
return !!env.ANTHROPIC_API_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAnthropicAuthToken(): boolean {
|
||||||
|
const env = loadClaudeMemEnv();
|
||||||
|
return !!env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuthMethodDescription(): string {
|
export function getAuthMethodDescription(): string {
|
||||||
if (hasAnthropicApiKey()) {
|
if (hasAnthropicApiKey()) {
|
||||||
return 'API key (from ~/.claude-mem/.env)';
|
return 'API key (from ~/.claude-mem/.env)';
|
||||||
}
|
}
|
||||||
|
if (hasAnthropicAuthToken()) {
|
||||||
|
return 'Gateway auth token (from ~/.claude-mem/.env)';
|
||||||
|
}
|
||||||
// Note: this is a quick sync hint for logging — the authoritative OAuth
|
// Note: this is a quick sync hint for logging — the authoritative OAuth
|
||||||
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
|
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
|
||||||
// spawn time. process.env may or may not carry a token here.
|
// spawn time. process.env may or may not carry a token here.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export class SettingsDefaultsManager {
|
|||||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||||
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
|
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
|
||||||
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
|
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
|
||||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key)
|
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
|
||||||
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
||||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
|
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
|
||||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
|
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
|
||||||
|
|||||||
@@ -174,9 +174,11 @@ export class ProcessRegistry {
|
|||||||
|
|
||||||
unregister(id: string): void {
|
unregister(id: string): void {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
const existing = this.entries.get(id);
|
||||||
this.entries.delete(id);
|
this.entries.delete(id);
|
||||||
this.runtimeProcesses.delete(id);
|
this.runtimeProcesses.delete(id);
|
||||||
this.persist();
|
this.persist();
|
||||||
|
if (existing?.type === 'sdk') notifySlotAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
@@ -213,16 +215,19 @@ export class ProcessRegistry {
|
|||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
|
let removedSdk = 0;
|
||||||
for (const [id, info] of this.entries) {
|
for (const [id, info] of this.entries) {
|
||||||
if (isPidAlive(info.pid)) continue;
|
if (isPidAlive(info.pid)) continue;
|
||||||
this.entries.delete(id);
|
this.entries.delete(id);
|
||||||
this.runtimeProcesses.delete(id);
|
this.runtimeProcesses.delete(id);
|
||||||
removed += 1;
|
removed += 1;
|
||||||
|
if (info.type === 'sdk') removedSdk += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
this.persist();
|
this.persist();
|
||||||
}
|
}
|
||||||
|
for (let i = 0; i < removedSdk; i += 1) notifySlotAvailable();
|
||||||
|
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
@@ -321,6 +326,9 @@ export class ProcessRegistry {
|
|||||||
this.runtimeProcesses.delete(record.id);
|
this.runtimeProcesses.delete(record.id);
|
||||||
}
|
}
|
||||||
this.persist();
|
this.persist();
|
||||||
|
for (const record of sessionRecords) {
|
||||||
|
if (record.type === 'sdk') notifySlotAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
||||||
sessionId: sessionIdNum,
|
sessionId: sessionIdNum,
|
||||||
@@ -428,6 +436,7 @@ export async function ensureSdkProcessExit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOTAL_PROCESS_HARD_CAP = 10;
|
const TOTAL_PROCESS_HARD_CAP = 10;
|
||||||
|
const SLOT_RECHECK_INTERVAL_MS = 5_000;
|
||||||
const slotWaiters: Array<() => void> = [];
|
const slotWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
function getActiveSdkCount(): number {
|
function getActiveSdkCount(): number {
|
||||||
@@ -439,7 +448,8 @@ function notifySlotAvailable(): void {
|
|||||||
if (waiter) waiter();
|
if (waiter) waiter();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> {
|
export async function waitForSlot(maxConcurrent: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
getProcessRegistry().pruneDeadEntries();
|
||||||
const activeCount = getActiveSdkCount();
|
const activeCount = getActiveSdkCount();
|
||||||
if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
|
if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
|
||||||
throw new Error(`Hard cap exceeded: ${activeCount} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`);
|
throw new Error(`Hard cap exceeded: ${activeCount} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`);
|
||||||
@@ -447,25 +457,55 @@ export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_
|
|||||||
|
|
||||||
if (activeCount < maxConcurrent) return;
|
if (activeCount < maxConcurrent) return;
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('waitForSlot aborted before queuing');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
|
logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
let recheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let abortHandler: (() => void) | null = null;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (recheckTimer) clearInterval(recheckTimer);
|
||||||
|
if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
|
||||||
const idx = slotWaiters.indexOf(onSlot);
|
const idx = slotWaiters.indexOf(onSlot);
|
||||||
if (idx >= 0) slotWaiters.splice(idx, 1);
|
if (idx >= 0) slotWaiters.splice(idx, 1);
|
||||||
reject(new Error(`Timed out waiting for agent pool slot after ${timeoutMs}ms`));
|
};
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
const onSlot = () => {
|
const onSlot = () => {
|
||||||
clearTimeout(timeout);
|
const count = getActiveSdkCount();
|
||||||
if (getActiveSdkCount() < maxConcurrent) {
|
if (count >= TOTAL_PROCESS_HARD_CAP) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`Hard cap exceeded: ${count} processes in registry (cap=${TOTAL_PROCESS_HARD_CAP}). Refusing to spawn more.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < maxConcurrent) {
|
||||||
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
slotWaiters.push(onSlot);
|
slotWaiters.push(onSlot);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
abortHandler = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('waitForSlot aborted'));
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
slotWaiters.push(onSlot);
|
slotWaiters.push(onSlot);
|
||||||
|
recheckTimer = setInterval(() => {
|
||||||
|
const removed = getProcessRegistry().pruneDeadEntries();
|
||||||
|
if (removed > 0) {
|
||||||
|
logger.info('PROCESS', 'Pruned stale process registry entries while waiting for agent slot', { removed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifySlotAvailable();
|
||||||
|
}, SLOT_RECHECK_INTERVAL_MS);
|
||||||
|
recheckTimer.unref?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +605,6 @@ export function spawnSdkProcess(
|
|||||||
logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid });
|
logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid });
|
||||||
}
|
}
|
||||||
registry.unregister(recordId);
|
registry.unregister(recordId);
|
||||||
notifySlotAvailable();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!child.stdin || !child.stdout || !child.stderr) {
|
if (!child.stdin || !child.stdout || !child.stderr) {
|
||||||
|
|||||||
Reference in New Issue
Block a user