Merge pull request #2302 from thedotmack/codex/remove-agent-pool-timeout

[codex] Remove agent pool timeout data loss
This commit is contained in:
Alex Newman
2026-05-05 14:46:28 -07:00
committed by GitHub
10 changed files with 541 additions and 266 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+221 -17
View File
@@ -7,6 +7,7 @@ import { homedir } from 'os';
import { dirname, join } from 'path';
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.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 {
ensureBun,
@@ -588,13 +589,143 @@ function mergeSettings(updates: Record<string, string>): boolean {
}
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> {
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
const persistClaudeProvider = () => {
const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude' });
if (wrote) log.info('Saved provider=claude to ~/.claude-mem/settings.json');
const persistClaudeProvider = (authMethod?: 'subscription' | 'api-key' | 'gateway') => {
const resolvedAuthMethod = authMethod ?? resolveClaudeAuthMethod();
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) {
@@ -611,29 +742,72 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
return initialProvider;
}
let selectedProvider: ProviderId;
if (options.provider) {
selectedProvider = options.provider;
} else {
const result = await p.select<ProviderId>({
message: 'Which LLM provider should claude-mem use to compress observations?',
const runClaudeAuthFlow = async (): Promise<void> => {
const resolvedAuthMethod = resolveClaudeAuthMethod();
const initialAccessMode: ClaudeAccessMode =
resolvedAuthMethod === 'subscription' ? 'subscription' : 'api-key';
const result = await p.select<ClaudeAccessMode>({
message: 'Do you use a subscription plan or an API key/gateway for the memory agent?',
options: [
{ value: 'claude', label: 'Claude Code auth (default — no extra setup, uses your existing Claude Code subscription)' },
{ value: 'gemini', label: 'Gemini API key (free tier available — fast and cheap)' },
{ value: 'openrouter', label: 'OpenRouter API key (BYO model — wide selection of frontier and open models)' },
{ value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' },
{ value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' },
],
initialValue: initialProvider,
initialValue: initialAccessMode,
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
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') {
persistClaudeProvider();
await runClaudeAuthFlow();
return 'claude';
}
@@ -652,7 +826,7 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const apiKeyResult = await p.password({
message: `Paste your ${providerLabel} API key:`,
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)) {
@@ -678,8 +852,9 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
'claude-sonnet-4-6',
'claude-opus-4-7',
]);
const allowCustomModel = resolveClaudeAuthMethod() === 'gateway';
if (options.model) {
if (options.model && !allowCustomModel) {
if (!allowed.has(options.model)) {
throw new Error(
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
@@ -691,10 +866,39 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
}
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;
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 result = await p.select<string>({
+1 -1
View File
@@ -150,7 +150,7 @@ export class ClaudeProvider {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
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 authMethod = getAuthMethodDescription();
@@ -147,16 +147,16 @@ export class SessionRoutes extends BaseRouteHandler {
const pendingStore = this.sessionManager.getPendingMessageStore();
try {
const cleared = pendingStore.clearPendingForSession(session.sessionDbId);
if (cleared > 0) {
logger.error('SESSION', `Cleared pending messages after generator error`, {
const reset = pendingStore.resetProcessingToPending(session.sessionDbId);
if (reset > 0) {
logger.warn('SESSION', `Reset processing messages after generator error`, {
sessionId: session.sessionDbId,
cleared
reset
});
}
} catch (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
}, normalizedDbError);
}
@@ -87,6 +87,7 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_WORKER_HOST',
'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL',
'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) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
+34 -5
View File
@@ -13,6 +13,10 @@ export const ENV_FILE_PATH = paths.envFile();
const BLOCKED_ENV_VARS = [
'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
'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
@@ -22,6 +26,7 @@ const BLOCKED_ENV_VARS = [
export interface ClaudeMemEnv {
ANTHROPIC_API_KEY?: string;
ANTHROPIC_BASE_URL?: string;
ANTHROPIC_AUTH_TOKEN?: string;
GEMINI_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 {
const lines: string[] = [
'# 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',
'',
];
@@ -83,6 +88,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
const result: ClaudeMemEnv = {};
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_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.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;
}
}
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) {
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) {
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) {
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
}
@@ -214,10 +230,15 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv;
// If the user already configured an ANTHROPIC_API_KEY in ~/.claude-mem/.env,
// honor that and skip OAuth lookup entirely. API key auth is preferred when
// explicitly configured because it's stateless and stable.
if (isolatedEnv.ANTHROPIC_API_KEY) {
// If the user already configured explicit Anthropic/gateway credentials in
// ~/.claude-mem/.env, honor those and skip OAuth lookup entirely. A bare
// ANTHROPIC_BASE_URL counts because gateways may be tokenless, and falling
// 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();
return isolatedEnv;
}
@@ -276,10 +297,18 @@ export function hasAnthropicApiKey(): boolean {
return !!env.ANTHROPIC_API_KEY;
}
export function hasAnthropicAuthToken(): boolean {
const env = loadClaudeMemEnv();
return !!env.ANTHROPIC_AUTH_TOKEN;
}
export function getAuthMethodDescription(): string {
if (hasAnthropicApiKey()) {
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
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
// spawn time. process.env may or may not carry a token here.
+1 -1
View File
@@ -74,7 +74,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
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_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
+47 -8
View File
@@ -174,9 +174,11 @@ export class ProcessRegistry {
unregister(id: string): void {
this.initialize();
const existing = this.entries.get(id);
this.entries.delete(id);
this.runtimeProcesses.delete(id);
this.persist();
if (existing?.type === 'sdk') notifySlotAvailable();
}
clear(): void {
@@ -213,16 +215,19 @@ export class ProcessRegistry {
this.initialize();
let removed = 0;
let removedSdk = 0;
for (const [id, info] of this.entries) {
if (isPidAlive(info.pid)) continue;
this.entries.delete(id);
this.runtimeProcesses.delete(id);
removed += 1;
if (info.type === 'sdk') removedSdk += 1;
}
if (removed > 0) {
this.persist();
}
for (let i = 0; i < removedSdk; i += 1) notifySlotAvailable();
return removed;
}
@@ -321,6 +326,9 @@ export class ProcessRegistry {
this.runtimeProcesses.delete(record.id);
}
this.persist();
for (const record of sessionRecords) {
if (record.type === 'sdk') notifySlotAvailable();
}
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
sessionId: sessionIdNum,
@@ -428,6 +436,7 @@ export async function ensureSdkProcessExit(
}
const TOTAL_PROCESS_HARD_CAP = 10;
const SLOT_RECHECK_INTERVAL_MS = 5_000;
const slotWaiters: Array<() => void> = [];
function getActiveSdkCount(): number {
@@ -439,7 +448,8 @@ function notifySlotAvailable(): void {
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();
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.`);
@@ -447,25 +457,55 @@ export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_
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...`);
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);
if (idx >= 0) slotWaiters.splice(idx, 1);
reject(new Error(`Timed out waiting for agent pool slot after ${timeoutMs}ms`));
}, timeoutMs);
};
const onSlot = () => {
clearTimeout(timeout);
if (getActiveSdkCount() < maxConcurrent) {
const count = getActiveSdkCount();
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();
} else {
slotWaiters.push(onSlot);
}
};
if (signal) {
abortHandler = () => {
cleanup();
reject(new Error('waitForSlot aborted'));
};
signal.addEventListener('abort', abortHandler, { once: true });
}
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 });
}
registry.unregister(recordId);
notifySlotAvailable();
});
if (!child.stdin || !child.stdout || !child.stderr) {