revert: roll back v12.3.3 (Issue Blowout 2026)

SessionStart context injection regressed in v12.3.3 — no memory
context is being delivered to new sessions. Rolling back to the
v12.3.2 tree state while the regression is investigated.

Reverts #2080.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-20 11:59:15 -07:00
parent 708a258d39
commit bfc7de377a
45 changed files with 367 additions and 1249 deletions
+11 -19
View File
@@ -44,8 +44,7 @@ export const sessionInitHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const { sessionId, prompt: rawPrompt } = input;
const cwd = input.cwd ?? process.cwd(); // Match context.ts fallback (#1918)
const { sessionId, cwd, prompt: rawPrompt } = input;
// Guard: Codex CLI and other platforms may not provide a session_id (#744)
if (!sessionId) {
@@ -70,23 +69,16 @@ export const sessionInitHandler: EventHandler = {
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
// Initialize session via HTTP - handles DB operations and privacy checks
let initResponse: Response;
try {
initResponse = await workerHttpRequest('/api/sessions/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
project,
prompt,
platformSource
})
});
} catch (err) {
// Worker unreachable — on Linux/WSL, hook may fire before worker is healthy (#1907)
logger.warn('HOOK', `session-init: worker request failed: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const initResponse = await workerHttpRequest('/api/sessions/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
project,
prompt,
platformSource
})
});
if (!initResponse.ok) {
// Log but don't throw - a worker 500 should not block the user's prompt
+10 -18
View File
@@ -84,24 +84,16 @@ export const summarizeHandler: EventHandler = {
const platformSource = normalizePlatformSource(input.platform);
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
let response: Response;
try {
response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage,
platformSource
}),
timeoutMs: SUMMARIZE_TIMEOUT_MS
});
} catch (err) {
// Network error, worker crash, or timeout — exit gracefully instead of
// bubbling to hook runner which exits code 2 and blocks session exit (#1901)
logger.warn('HOOK', `Stop hook: summarize request failed: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage,
platformSource
}),
timeoutMs: SUMMARIZE_TIMEOUT_MS
});
if (!response.ok) {
return { continue: true, suppressOutput: true };
+3 -34
View File
@@ -97,37 +97,6 @@ interface SessionDeletedEvent {
const WORKER_BASE_URL = "http://127.0.0.1:37777";
const MAX_TOOL_RESPONSE_LENGTH = 1000;
// ============================================================================
// Auth Token (reads from DATA_DIR/worker-auth-token)
// ============================================================================
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { homedir } from "os";
let cachedAuthToken: string | null = null;
function getAuthToken(): string | null {
if (cachedAuthToken) return cachedAuthToken;
const tokenPath = join(
process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), ".claude-mem"),
"worker-auth-token",
);
if (!existsSync(tokenPath)) return null;
const token = readFileSync(tokenPath, "utf-8").trim();
if (token.length >= 32) {
cachedAuthToken = token;
return token;
}
return null;
}
function getAuthHeaders(): Record<string, string> {
const token = getAuthToken();
if (!token) return { "Content-Type": "application/json" };
return { "Content-Type": "application/json", Authorization: `Bearer ${token}` };
}
// ============================================================================
// Worker HTTP Client
// ============================================================================
@@ -140,7 +109,7 @@ async function workerPost(
try {
response = await fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST",
headers: getAuthHeaders(),
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch (error: unknown) {
@@ -165,7 +134,7 @@ function workerPostFireAndForget(
): void {
fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST",
headers: getAuthHeaders(),
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
@@ -177,7 +146,7 @@ function workerPostFireAndForget(
async function workerGetText(path: string): Promise<string | null> {
try {
const response = await fetch(`${WORKER_BASE_URL}${path}`, { headers: getAuthHeaders() });
const response = await fetch(`${WORKER_BASE_URL}${path}`);
if (!response.ok) {
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
return null;
-1
View File
@@ -10,6 +10,5 @@
export {
createMiddleware,
requireLocalhost,
requireAuth,
summarizeRequestBody
} from '../worker/http/middleware.js';
+1 -9
View File
@@ -15,7 +15,7 @@ import * as fs from 'fs';
import path from 'path';
import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
import { logger } from '../../utils/logger.js';
import { createMiddleware, summarizeRequestBody, requireLocalhost, requireAuth } from './Middleware.js';
import { createMiddleware, summarizeRequestBody, requireLocalhost } from './Middleware.js';
import { errorHandler, notFoundHandler } from './ErrorHandler.js';
import { getSupervisor } from '../../supervisor/index.js';
import { isPidAlive } from '../../supervisor/process-registry.js';
@@ -155,14 +155,6 @@ export class Server {
private setupMiddleware(): void {
const middlewares = createMiddleware(summarizeRequestBody);
middlewares.forEach(mw => this.app.use(mw));
// Bearer token auth for all /api/* routes except health and readiness (#1932/#1933)
this.app.use('/api', (req, res, next) => {
if (req.path === '/health' || req.path === '/readiness') {
return next();
}
requireAuth(req, res, next);
});
}
/**
@@ -477,25 +477,6 @@ export class PendingMessageStore {
return result.changes;
}
/**
* Clear failed messages older than the given threshold.
* Preserves recent failures for inspection and manual retry.
* @param thresholdMs - Only delete failures older than this many milliseconds
* @returns Number of messages deleted
*/
clearFailedOlderThan(thresholdMs: number): number {
const cutoff = Date.now() - thresholdMs;
// Use COALESCE to prefer the most recent failure timestamp over creation time.
// failed_at_epoch is set by session-level failures, completed_at_epoch by markFailed().
const stmt = this.db.prepare(`
DELETE FROM pending_messages
WHERE status = 'failed'
AND COALESCE(failed_at_epoch, completed_at_epoch, started_processing_at_epoch, created_at_epoch) < ?
`);
const result = stmt.run(cutoff);
return result.changes;
}
/**
* Clear all pending, processing, and failed messages from the queue
* Keeps only processed messages (for history)
+10 -21
View File
@@ -36,7 +36,7 @@ export class SessionSearch {
// Cache FTS5 availability once at construction (avoids DDL probe on every query)
this._fts5Available = this.isFts5Available();
// Ensure FTS tables exist — may downgrade _fts5Available if creation fails
// Ensure FTS tables exist
this.ensureFTSTables();
}
@@ -84,7 +84,6 @@ export class SessionSearch {
logger.info('DB', 'FTS5 tables created successfully');
} catch (error) {
// FTS5 creation failed at runtime despite probe succeeding — degrade gracefully
this._fts5Available = false;
logger.warn('DB', 'FTS5 table creation failed — search will use ChromaDB and LIKE queries', {}, error instanceof Error ? error : undefined);
}
}
@@ -328,17 +327,14 @@ export class SessionSearch {
LIMIT ? OFFSET ?
`;
// Escape FTS5 special characters: wrap in quotes to treat as literal phrase
const escapedQuery = '"' + query.replace(/"/g, '""') + '"';
params.unshift(escapedQuery);
params.unshift(query);
params.push(limit, offset);
try {
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
} catch (error) {
// Re-throw so callers can distinguish FTS failure from "no results"
logger.warn('DB', 'FTS5 observation search failed', {}, error instanceof Error ? error : undefined);
throw error;
logger.warn('DB', 'FTS5 observation search failed, returning empty', {}, error instanceof Error ? error : undefined);
return [];
}
}
@@ -387,9 +383,7 @@ export class SessionSearch {
const orderClause = orderBy === 'date_asc'
? 'ORDER BY s.created_at_epoch ASC'
: orderBy === 'date_desc'
? 'ORDER BY s.created_at_epoch DESC'
: 'ORDER BY session_summaries_fts.rank ASC';
: 'ORDER BY session_summaries_fts.rank ASC';
const sql = `
SELECT s.*, s.discovery_tokens
@@ -401,17 +395,14 @@ export class SessionSearch {
LIMIT ? OFFSET ?
`;
// Escape FTS5 special characters: wrap in quotes to treat as literal phrase
const escapedQuery = '"' + query.replace(/"/g, '""') + '"';
params.unshift(escapedQuery);
params.unshift(query);
params.push(limit, offset);
try {
return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[];
} catch (error) {
// Re-throw so callers can distinguish FTS failure from "no results"
logger.warn('DB', 'FTS5 session search failed', {}, error instanceof Error ? error : undefined);
throw error;
logger.warn('DB', 'FTS5 session search failed, returning empty', {}, error instanceof Error ? error : undefined);
return [];
}
}
@@ -654,10 +645,8 @@ export class SessionSearch {
}
// LIKE fallback for user prompts text search (no FTS table for this entity)
// Escape LIKE metacharacters so %, _, and \ in user input are treated as literals
const escapedQuery = query.replace(/[\\%_]/g, '\\$&');
baseConditions.push("up.prompt_text LIKE ? ESCAPE '\\'");
params.push(`%${escapedQuery}%`);
baseConditions.push('up.prompt_text LIKE ?');
params.push(`%${query}%`);
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
const orderClause = orderBy === 'date_asc'
-15
View File
@@ -1,10 +1,8 @@
import path from 'path';
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
import { observationHandler } from '../../cli/handlers/observation.js';
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { DATA_DIR } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import { getProjectContext } from '../../utils/project-name.js';
import { writeAgentsMd } from '../../utils/agents-md-utils.js';
@@ -359,19 +357,6 @@ export class TranscriptEventProcessor {
const contextUrl = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`;
const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);
// Validate resolved path stays within allowed directories (#1934)
const resolvedAgentsPath = path.resolve(agentsPath);
const allowedRoots = [path.resolve(cwd), path.resolve(DATA_DIR)];
const isPathSafe = allowedRoots.some(root => resolvedAgentsPath.startsWith(root + path.sep) || resolvedAgentsPath === root);
if (!isPathSafe) {
logger.warn('SECURITY', 'Rejected path traversal attempt in watch.context.path', {
original: watch.context.path,
resolved: resolvedAgentsPath,
allowedRoots
});
return;
}
let response: Awaited<ReturnType<typeof workerHttpRequest>>;
try {
response = await workerHttpRequest(contextUrl);
+12 -18
View File
@@ -28,7 +28,6 @@ import { sanitizeEnv } from '../supervisor/env-sanitizer.js';
// ensure the worker daemon is up without importing this entire module — which
// transitively pulls in the SQLite database layer via ChromaSync/DatabaseManager.
import { ensureWorkerStarted as ensureWorkerStartedShared } from './worker-spawner.js';
import { RestartGuard } from './worker/RestartGuard.js';
// Re-export for backward compatibility — canonical implementation in shared/plugin-state.ts
export { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
@@ -483,7 +482,7 @@ export class WorkerService {
// Best-effort loopback MCP self-check
getSupervisor().assertCanSpawn('mcp server');
const transport = new StdioClientTransport({
command: process.execPath, // Use resolved path, not bare 'node' which fails on non-interactive PATH (#1876)
command: 'node',
args: [mcpServerPath],
env: sanitizeEnv(process.env)
});
@@ -559,14 +558,12 @@ export class WorkerService {
}
}
// Purge stale failed pending messages to prevent unbounded queue growth (#1957)
// Only remove failures older than 1 hour to preserve recent failures for inspection/retry
// Purge failed pending messages to prevent unbounded queue growth (#1957)
try {
const pendingStore = this.sessionManager.getPendingMessageStore();
const FAILED_MESSAGE_RETENTION_MS = 60 * 60 * 1000; // 1 hour
const purged = pendingStore.clearFailedOlderThan(FAILED_MESSAGE_RETENTION_MS);
const purged = pendingStore.clearFailed();
if (purged > 0) {
logger.info('SYSTEM', `Purged ${purged} stale failed pending messages (older than 1h)`);
logger.info('SYSTEM', `Purged ${purged} failed pending messages`);
}
} catch (e) {
if (e instanceof Error) {
@@ -819,19 +816,17 @@ export class WorkerService {
}
// Fall through to pending-work restart below
}
if (pendingCount > 0) {
// Windowed restart guard: only blocks tight-loop restarts, not spread-out ones (#2053)
if (!session.restartGuard) session.restartGuard = new RestartGuard();
const restartAllowed = session.restartGuard.recordRestart();
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1; // Keep for logging
const MAX_PENDING_RESTARTS = 3;
if (!restartAllowed) {
logger.error('SYSTEM', 'Restart guard tripped: too many restarts in window, stopping to prevent runaway costs', {
if (pendingCount > 0) {
// Track consecutive pending-work restarts to prevent infinite loops (e.g. FK errors)
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
if (session.consecutiveRestarts > MAX_PENDING_RESTARTS) {
logger.error('SYSTEM', 'Exceeded max pending-work restarts, stopping to prevent infinite loop', {
sessionId: session.sessionDbId,
pendingCount,
restartsInWindow: session.restartGuard.restartsInWindow,
windowMs: session.restartGuard.windowMs,
maxRestarts: session.restartGuard.maxRestarts
consecutiveRestarts: session.consecutiveRestarts
});
session.consecutiveRestarts = 0;
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
@@ -851,7 +846,6 @@ export class WorkerService {
} else {
// Successful completion with no pending work — clean up session
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
session.restartGuard?.recordSuccess();
session.consecutiveRestarts = 0;
this.sessionManager.removeSessionImmediate(session.sessionDbId);
}
+1 -3
View File
@@ -3,7 +3,6 @@
*/
import type { Response } from 'express';
import type { RestartGuard } from './worker/RestartGuard.js';
// ============================================================================
// Active Session Types
@@ -35,8 +34,7 @@ export interface ActiveSession {
earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps)
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
consecutiveRestarts: number; // DEPRECATED: use restartGuard. Kept for logging compat.
restartGuard?: RestartGuard;
consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
forceInit?: boolean; // Force fresh SDK session (skip resume)
idleTimedOut?: boolean; // Set when session exits due to idle timeout (prevents restart loop)
lastGeneratorActivity: number; // Timestamp of last generator progress (for stale detection, Issue #1099)
+1 -17
View File
@@ -115,15 +115,10 @@ function notifySlotAvailable(): void {
* Wait for a pool slot to become available (promise-based, not polling)
* @param maxConcurrent Max number of concurrent agents
* @param timeoutMs Max time to wait before giving up
* @param evictIdleSession Optional callback to evict an idle session when all slots are full (#1868)
*/
const TOTAL_PROCESS_HARD_CAP = 10;
export async function waitForSlot(
maxConcurrent: number,
timeoutMs: number = 60_000,
evictIdleSession?: () => boolean
): Promise<void> {
export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> {
// Hard cap: refuse to spawn if too many processes exist regardless of pool accounting
const activeCount = getActiveCount();
if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
@@ -132,17 +127,6 @@ export async function waitForSlot(
if (activeCount < maxConcurrent) return;
// Try to evict an idle session before waiting (#1868)
// Idle sessions hold pool slots during their 3-min idle timeout, blocking new sessions
// that would timeout after 60s. Eviction aborts the idle session asynchronously —
// the freed slot is picked up by the waiter mechanism below.
if (evictIdleSession) {
const evicted = evictIdleSession();
if (evicted) {
logger.info('PROCESS', 'Evicted idle session to free pool slot for waiting request');
}
}
logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
return new Promise<void>((resolve, reject) => {
-70
View File
@@ -1,70 +0,0 @@
/**
* Time-windowed restart guard.
* Prevents tight-loop restarts (bug) while allowing legitimate occasional restarts
* over long sessions. Replaces the flat consecutiveRestarts counter that stranded
* pending messages after just 3 restarts over any timeframe (#2053).
*/
const RESTART_WINDOW_MS = 60_000; // Only count restarts within last 60 seconds
const MAX_WINDOWED_RESTARTS = 10; // 10 restarts in 60s = runaway loop
const DECAY_AFTER_SUCCESS_MS = 5 * 60_000; // Clear history after 5min of uninterrupted success
export class RestartGuard {
private restartTimestamps: number[] = [];
private lastSuccessfulProcessing: number | null = null;
/**
* Record a restart and check if the guard should trip.
* @returns true if the restart is ALLOWED, false if it should be BLOCKED
*/
recordRestart(): boolean {
const now = Date.now();
// Decay: clear history only after real success + 5min of uninterrupted success
if (this.lastSuccessfulProcessing !== null
&& now - this.lastSuccessfulProcessing >= DECAY_AFTER_SUCCESS_MS) {
this.restartTimestamps = [];
this.lastSuccessfulProcessing = null;
}
// Prune old timestamps outside the window
this.restartTimestamps = this.restartTimestamps.filter(
ts => now - ts < RESTART_WINDOW_MS
);
// Record this restart
this.restartTimestamps.push(now);
// Check if we've exceeded the cap within the window
return this.restartTimestamps.length <= MAX_WINDOWED_RESTARTS;
}
/**
* Call when a message is successfully processed to update the success timestamp.
*/
recordSuccess(): void {
this.lastSuccessfulProcessing = Date.now();
}
/**
* Get the number of restarts in the current window (for logging).
*/
get restartsInWindow(): number {
const now = Date.now();
return this.restartTimestamps.filter(ts => now - ts < RESTART_WINDOW_MS).length;
}
/**
* Get the window size in ms (for logging).
*/
get windowMs(): number {
return RESTART_WINDOW_MS;
}
/**
* Get the max allowed restarts (for logging).
*/
get maxRestarts(): number {
return MAX_WINDOWED_RESTARTS;
}
}
+1 -3
View File
@@ -90,11 +90,9 @@ export class SDKAgent {
}
// Wait for agent pool slot (configurable via CLAUDE_MEM_MAX_CONCURRENT_AGENTS)
// Pass idle session eviction callback to prevent pool deadlock (#1868):
// idle sessions hold slots during 3-min idle wait, blocking new sessions
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
await waitForSlot(maxConcurrent, 60_000, () => this.sessionManager.evictIdlestSession());
await waitForSlot(maxConcurrent);
// Build isolated environment from ~/.claude-mem/.env
// This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files
+70 -217
View File
@@ -67,20 +67,8 @@ export class SearchManager {
return await this.chromaSync.queryChroma(query, limit, whereFilter);
}
private async searchChromaForTimeline(query: string, ninetyDaysAgo: number, project?: string): Promise<ObservationSearchResult[]> {
// Build where filter scoped to observations only + project if provided
let whereFilter: Record<string, any> = { doc_type: 'observation' };
if (project) {
const projectFilter = {
$or: [
{ project },
{ merged_into_project: project }
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
const chromaResults = await this.queryChroma(query, 100, whereFilter);
private async searchChromaForTimeline(query: string, ninetyDaysAgo: number): Promise<ObservationSearchResult[]> {
const chromaResults = await this.queryChroma(query, 100);
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
if (chromaResults?.ids && chromaResults.ids.length > 0) {
@@ -90,7 +78,7 @@ export class SearchManager {
});
if (recentIds.length > 0) {
return this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: 1, project });
return this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: 1 });
}
}
return [];
@@ -298,20 +286,14 @@ export class SearchManager {
// ChromaDB not initialized - fall back to FTS5 keyword search (#1913, #2048)
else if (query) {
logger.debug('SEARCH', 'ChromaDB not initialized — falling back to FTS5 keyword search', {});
try {
if (searchObservations) {
observations = this.sessionSearch.searchObservations(query, { ...options, type: obs_type, concepts, files });
}
if (searchSessions) {
sessions = this.sessionSearch.searchSessions(query, options);
}
if (searchPrompts) {
prompts = this.sessionSearch.searchUserPrompts(query, options);
}
} catch (ftsError) {
const errorObject = ftsError instanceof Error ? ftsError : new Error(String(ftsError));
logger.error('WORKER', 'FTS5 fallback search failed', {}, errorObject);
chromaFailed = true;
if (searchObservations) {
observations = this.sessionSearch.searchObservations(query, { ...options, type: obs_type, concepts, files });
}
if (searchSessions) {
sessions = this.sessionSearch.searchSessions(query, options);
}
if (searchPrompts) {
prompts = this.sessionSearch.searchUserPrompts(query, options);
}
}
@@ -487,25 +469,13 @@ export class SearchManager {
logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {});
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
try {
results = await this.searchChromaForTimeline(query, ninetyDaysAgo, project);
results = await this.searchChromaForTimeline(query, ninetyDaysAgo);
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for timeline, continuing without semantic results', {}, errorObject);
}
}
// FTS fallback when Chroma is unavailable or returned no results
if (results.length === 0) {
try {
const ftsResults = this.sessionSearch.searchObservations(query, { project, limit: 1 });
if (ftsResults.length > 0) {
results = ftsResults;
}
} catch (ftsError) {
logger.warn('SEARCH', 'FTS fallback failed for timeline', {}, ftsError instanceof Error ? ftsError : undefined);
}
}
if (results.length === 0) {
return {
content: [{
@@ -957,55 +927,26 @@ export class SearchManager {
if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search (Chroma + SQLite)', {});
// Build Chroma where filter with doc_type and project scope
let whereFilter: Record<string, any> = { doc_type: 'observation' };
if (options.project) {
const projectFilter = {
$or: [
{ project: options.project },
{ merged_into_project: options.project }
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
// Step 1: Chroma semantic search (top 100)
try {
const chromaResults = await this.queryChroma(query, 100, whereFilter);
logger.debug('SEARCH', 'Chroma returned semantic matches', { matchCount: chromaResults.ids.length });
const chromaResults = await this.queryChroma(query, 100);
logger.debug('SEARCH', 'Chroma returned semantic matches', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project });
logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length });
}
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length });
}
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for observations, falling back to FTS', {}, errorObject);
}
}
// FTS fallback when Chroma is unavailable or returned no results
if (results.length === 0) {
try {
const ftsResults = this.sessionSearch.searchObservations(query, options);
if (ftsResults.length > 0) {
results = ftsResults;
}
} catch (ftsError) {
logger.warn('SEARCH', 'FTS fallback failed for observations', {}, ftsError instanceof Error ? ftsError : undefined);
}
}
@@ -1043,55 +984,26 @@ export class SearchManager {
if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for sessions', {});
// Build Chroma where filter with doc_type and project scope
let whereFilter: Record<string, any> = { doc_type: 'session_summary' };
if (options.project) {
const projectFilter = {
$or: [
{ project: options.project },
{ merged_into_project: options.project }
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
// Step 1: Chroma semantic search (top 100)
try {
const chromaResults = await this.queryChroma(query, 100, whereFilter);
logger.debug('SEARCH', 'Chroma returned semantic matches for sessions', { matchCount: chromaResults.ids.length });
const chromaResults = await this.queryChroma(query, 100, { doc_type: 'session_summary' });
logger.debug('SEARCH', 'Chroma returned semantic matches for sessions', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getSessionSummariesByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project });
logger.debug('SEARCH', 'Hydrated sessions from SQLite', { count: results.length });
}
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getSessionSummariesByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated sessions from SQLite', { count: results.length });
}
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for sessions, falling back to FTS', {}, errorObject);
}
}
// FTS fallback when Chroma is unavailable or returned no results
if (results.length === 0) {
try {
const ftsResults = this.sessionSearch.searchSessions(query, options);
if (ftsResults.length > 0) {
results = ftsResults;
}
} catch (ftsError) {
logger.warn('SEARCH', 'FTS fallback failed for sessions', {}, ftsError instanceof Error ? ftsError : undefined);
}
}
@@ -1129,55 +1041,26 @@ export class SearchManager {
if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for user prompts', {});
// Build Chroma where filter with doc_type and project scope
let whereFilter: Record<string, any> = { doc_type: 'user_prompt' };
if (options.project) {
const projectFilter = {
$or: [
{ project: options.project },
{ merged_into_project: options.project }
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
// Step 1: Chroma semantic search (top 100)
try {
const chromaResults = await this.queryChroma(query, 100, whereFilter);
logger.debug('SEARCH', 'Chroma returned semantic matches for prompts', { matchCount: chromaResults.ids.length });
const chromaResults = await this.queryChroma(query, 100, { doc_type: 'user_prompt' });
logger.debug('SEARCH', 'Chroma returned semantic matches for prompts', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getUserPromptsByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project });
logger.debug('SEARCH', 'Hydrated user prompts from SQLite', { count: results.length });
}
// Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) {
const limit = options.limit || 20;
results = this.sessionStore.getUserPromptsByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated user prompts from SQLite', { count: results.length });
}
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for user prompts, falling back to FTS', {}, errorObject);
}
}
// FTS fallback when Chroma is unavailable or returned no results
if (results.length === 0 && query) {
try {
const ftsResults = this.sessionSearch.searchUserPrompts(query, options);
if (ftsResults.length > 0) {
results = ftsResults;
}
} catch (ftsError) {
logger.warn('SEARCH', 'FTS fallback failed for user prompts', {}, ftsError instanceof Error ? ftsError : undefined);
}
}
@@ -1819,53 +1702,23 @@ export class SearchManager {
// Use hybrid search if available
if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {});
const chromaResults = await this.queryChroma(query, 100);
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults.ids.length });
// Build Chroma where filter scoped to observations + project if provided
let whereFilter: Record<string, any> = { doc_type: 'observation' };
if (project) {
const projectFilter = {
$or: [
{ project },
{ merged_into_project: project }
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
if (chromaResults.ids.length > 0) {
// Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
try {
const chromaResults = await this.queryChroma(query, 100, whereFilter);
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults.ids.length });
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
if (chromaResults.ids.length > 0) {
// Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
});
logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
if (recentIds.length > 0) {
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: mode === 'auto' ? 1 : limit, project });
logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length });
}
if (recentIds.length > 0) {
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: mode === 'auto' ? 1 : limit });
logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length });
}
} catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for timeline by query, falling back to FTS', {}, errorObject);
}
}
// FTS fallback when Chroma is unavailable or returned no results
if (results.length === 0) {
try {
const ftsResults = this.sessionSearch.searchObservations(query, { project, limit: mode === 'auto' ? 1 : limit });
if (ftsResults.length > 0) {
results = ftsResults;
}
} catch (ftsError) {
logger.warn('SEARCH', 'FTS fallback failed for timeline by query', {}, ftsError instanceof Error ? ftsError : undefined);
}
}
+1 -41
View File
@@ -17,7 +17,6 @@ import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
import { getSupervisor } from '../../supervisor/index.js';
import { MAX_CONSECUTIVE_SUMMARY_FAILURES } from '../../sdk/prompts.js';
import { RestartGuard } from './RestartGuard.js';
/** Idle threshold before a stuck generator (zombie subprocess) is force-killed. */
export const MAX_GENERATOR_IDLE_MS = 5 * 60 * 1000; // 5 minutes
@@ -225,8 +224,7 @@ export class SessionManager {
earliestPendingTimestamp: null,
conversationHistory: [], // Initialize empty - will be populated by agents
currentProvider: null, // Will be set when generator starts
consecutiveRestarts: 0, // DEPRECATED: use restartGuard. Kept for logging compat.
restartGuard: new RestartGuard(),
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
lastGeneratorActivity: Date.now(), // Initialize for stale detection (Issue #1099)
consecutiveSummaryFailures: 0, // Circuit breaker for summary retry loop (#1633)
@@ -467,44 +465,6 @@ export class SessionManager {
}
}
/**
* Evict the idlest session to free a pool slot (#1868).
* An "idle" session has an active generator but no pending work it's sitting
* in the 3-min idle wait before subprocess cleanup. Evicting it triggers abort
* which kills the subprocess and frees the pool slot for a waiting new session.
* @returns true if a session was evicted, false if no idle sessions found
*/
evictIdlestSession(): boolean {
let idlestSessionId: number | null = null;
let oldestActivity = Infinity;
for (const [sessionDbId, session] of this.sessions) {
if (!session.generatorPromise) continue; // No generator = no slot held
const pendingCount = this.getPendingStore().getPendingCount(sessionDbId);
if (pendingCount > 0) continue; // Has work to do, don't evict
// Pick the session with the oldest lastGeneratorActivity (idlest)
if (session.lastGeneratorActivity < oldestActivity) {
oldestActivity = session.lastGeneratorActivity;
idlestSessionId = sessionDbId;
}
}
if (idlestSessionId === null) return false;
const session = this.sessions.get(idlestSessionId);
if (!session) return false;
logger.info('SESSION', 'Evicting idle session to free pool slot for new request (#1868)', {
sessionDbId: idlestSessionId,
idleDurationMs: Date.now() - oldestActivity
});
session.idleTimedOut = true;
session.abortController.abort();
return true;
}
/**
* Reap sessions with no active generator and no pending work that have been idle too long.
* Also reaps sessions whose generator has been stuck (no lastGeneratorActivity update) for
@@ -207,8 +207,6 @@ export async function processAgentResponse(
}
if (session.processingMessageIds.length > 0) {
logger.debug('QUEUE', `CONFIRMED_BATCH | sessionDbId=${session.sessionDbId} | count=${session.processingMessageIds.length} | ids=[${session.processingMessageIds.join(',')}]`);
// Record successful processing so restart guard decay is anchored to real successes
session.restartGuard?.recordSuccess();
}
// Clear the tracking array after confirmation
session.processingMessageIds = [];
+2 -57
View File
@@ -9,7 +9,6 @@ import express, { Request, Response, NextFunction, RequestHandler } from 'expres
import cors from 'cors';
import path from 'path';
import { getPackageRoot } from '../../../shared/paths.js';
import { getAuthToken } from '../../../shared/auth-token.js';
import { logger } from '../../../utils/logger.js';
/**
@@ -22,8 +21,8 @@ export function createMiddleware(
): RequestHandler[] {
const middlewares: RequestHandler[] = [];
// JSON parsing with 5mb limit (#1935)
middlewares.push(express.json({ limit: '5mb' }));
// JSON parsing with 50mb limit
middlewares.push(express.json({ limit: '50mb' }));
// CORS - restrict to localhost origins only
middlewares.push(cors({
@@ -43,39 +42,6 @@ export function createMiddleware(
credentials: false
}));
// Simple in-memory rate limiter (#1935)
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 300; // 300 requests per minute per IP
const rateLimiter: RequestHandler = (req, res, next) => {
const clientIp = req.ip || 'unknown';
const now = Date.now();
let entry = requestCounts.get(clientIp);
if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
requestCounts.set(clientIp, entry);
}
// Lazy cleanup: remove expired entries when map grows large
if (requestCounts.size > 100) {
for (const [ip, e] of requestCounts) {
if (now >= e.resetAt) requestCounts.delete(ip);
}
}
entry.count++;
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
res.status(429).json({ error: 'Rate limit exceeded' });
return;
}
next();
};
middlewares.push(rateLimiter);
// HTTP request/response logging
middlewares.push((req: Request, res: Response, next: NextFunction) => {
// Skip logging for static assets, health checks, and polling endpoints
@@ -140,27 +106,6 @@ export function requireLocalhost(req: Request, res: Response, next: NextFunction
next();
}
/**
* Bearer token auth middleware (#1932/#1933).
* Requires Authorization: Bearer <token> on all API requests.
* Token is auto-generated and stored in DATA_DIR/worker-auth-token.
*/
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return;
}
const token = authHeader.slice('Bearer '.length);
if (token !== getAuthToken()) {
res.status(401).json({ error: 'Invalid bearer token' });
return;
}
next();
}
/**
* Summarize request body for logging
* Used to avoid logging sensitive data or large payloads
@@ -382,13 +382,11 @@ export class DataRoutes extends BaseRouteHandler {
}
// Import observations (depends on sessions)
const importedObservations: Array<{ id: number; obs: typeof observations[0] }> = [];
if (Array.isArray(observations)) {
for (const obs of observations) {
const result = store.importObservation(obs);
if (result.imported) {
stats.observationsImported++;
importedObservations.push({ id: result.id, obs });
} else {
stats.observationsSkipped++;
}
@@ -400,53 +398,6 @@ export class DataRoutes extends BaseRouteHandler {
if (stats.observationsImported > 0) {
store.rebuildObservationsFTSIndex();
}
// Sync imported observations to ChromaDB for vector search.
// Fire-and-forget: Chroma sync failure should not block the import response.
// Bounded concurrency to prevent overwhelming Chroma on large imports.
const chromaSync = this.dbManager.getChromaSync();
if (chromaSync && importedObservations.length > 0) {
const CHROMA_SYNC_CONCURRENCY = 8;
const safeParseJson = (val: string | null): string[] => {
if (!val) return [];
try { return JSON.parse(val); } catch { return []; }
};
const syncOne = async ({ id, obs }: { id: number; obs: any }) => {
const parsedObs = {
type: obs.type || 'discovery',
title: obs.title || null,
subtitle: obs.subtitle || null,
facts: safeParseJson(obs.facts),
narrative: obs.narrative || null,
concepts: safeParseJson(obs.concepts),
files_read: safeParseJson(obs.files_read),
files_modified: safeParseJson(obs.files_modified),
};
await chromaSync.syncObservation(
id,
obs.memory_session_id,
obs.project,
parsedObs,
obs.prompt_number || 0,
obs.created_at_epoch,
obs.discovery_tokens || 0
).catch(err => {
logger.error('CHROMA', 'Import ChromaDB sync failed', { id }, err as Error);
});
};
// Fire-and-forget: process in batches but don't block the response
(async () => {
for (let i = 0; i < importedObservations.length; i += CHROMA_SYNC_CONCURRENCY) {
const batch = importedObservations.slice(i, i + CHROMA_SYNC_CONCURRENCY);
await Promise.all(batch.map(syncOne));
}
})().catch(err => {
logger.error('CHROMA', 'Import ChromaDB batch sync failed', {}, err as Error);
});
}
}
// Import prompts (depends on sessions)
@@ -24,7 +24,6 @@ import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
import { getProjectContext } from '../../../../utils/project-name.js';
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
import { RestartGuard } from '../../RestartGuard.js';
export class SessionRoutes extends BaseRouteHandler {
private completionHandler: SessionCompletionHandler;
@@ -280,10 +279,9 @@ export class SessionRoutes extends BaseRouteHandler {
if (wasAborted) {
logger.info('SESSION', `Generator aborted`, { sessionId: sessionDbId });
} else {
logger.error('SESSION', `Generator exited unexpectedly`, { sessionId: sessionDbId });
}
// Don't log "exited unexpectedly" here — a non-abort exit is normal when
// the SDK subprocess completes its work. The crash-recovery block below
// checks pendingCount to distinguish real crashes from clean exits (#1876).
session.generatorPromise = null;
session.currentProvider = null;
@@ -292,6 +290,7 @@ export class SessionRoutes extends BaseRouteHandler {
// Crash recovery: If not aborted and still has work, restart (with limit)
if (!wasAborted) {
const pendingStore = this.sessionManager.getPendingMessageStore();
const MAX_CONSECUTIVE_RESTARTS = 3;
let pendingCount: number;
try {
@@ -310,18 +309,14 @@ export class SessionRoutes extends BaseRouteHandler {
return;
}
// Windowed restart guard: only blocks tight-loop restarts, not spread-out ones (#2053)
if (!session.restartGuard) session.restartGuard = new RestartGuard();
const restartAllowed = session.restartGuard.recordRestart();
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1; // Keep for logging
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
if (!restartAllowed) {
logger.error('SESSION', `CRITICAL: Restart guard tripped — too many restarts in window, stopping to prevent runaway costs`, {
if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) {
logger.error('SESSION', `CRITICAL: Generator restart limit exceeded - stopping to prevent runaway costs`, {
sessionId: sessionDbId,
pendingCount,
restartsInWindow: session.restartGuard.restartsInWindow,
windowMs: session.restartGuard.windowMs,
maxRestarts: session.restartGuard.maxRestarts,
consecutiveRestarts: session.consecutiveRestarts,
maxRestarts: MAX_CONSECUTIVE_RESTARTS,
action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.'
});
// Don't restart - abort to prevent further API calls
@@ -333,8 +328,7 @@ export class SessionRoutes extends BaseRouteHandler {
sessionId: sessionDbId,
pendingCount,
consecutiveRestarts: session.consecutiveRestarts,
restartsInWindow: session.restartGuard!.restartsInWindow,
maxRestarts: session.restartGuard!.maxRestarts
maxRestarts: MAX_CONSECUTIVE_RESTARTS
});
// Abort OLD controller before replacing to prevent child process leaks
@@ -10,7 +10,6 @@ import path from 'path';
import { readFileSync, existsSync } from 'fs';
import { logger } from '../../../../utils/logger.js';
import { getPackageRoot } from '../../../../shared/paths.js';
import { getAuthToken } from '../../../../shared/auth-token.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import { DatabaseManager } from '../../DatabaseManager.js';
import { SessionManager } from '../../SessionManager.js';
@@ -67,10 +66,7 @@ export class ViewerRoutes extends BaseRouteHandler {
throw new Error('Viewer UI not found at any expected location');
}
let html = readFileSync(viewerPath, 'utf-8');
// Inject auth token so viewer can authenticate API requests (#1932/#1933)
const tokenScript = `<script>window.__CLAUDE_MEM_AUTH_TOKEN__="${getAuthToken()}";</script>`;
html = html.replace('</head>', `${tokenScript}</head>`);
const html = readFileSync(viewerPath, 'utf-8');
res.setHeader('Content-Type', 'text/html');
res.send(html);
});
+1 -1
View File
@@ -85,7 +85,7 @@ export class SettingsDefaultsManager {
private static readonly DEFAULTS: SettingsDefaults = {
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: String(37700 + ((process.getuid?.() ?? 77) % 100)),
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
// AI Provider Configuration
-33
View File
@@ -1,33 +0,0 @@
import { randomBytes } from 'crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { DATA_DIR } from './paths.js';
const TOKEN_FILENAME = 'worker-auth-token';
let cachedToken: string | null = null;
/**
* Get or generate the bearer token for worker API auth.
* Token is stored in DATA_DIR/worker-auth-token and cached in memory.
* All API requests must include this as: Authorization: Bearer <token>
*/
export function getAuthToken(): string {
if (cachedToken) return cachedToken;
const tokenPath = join(DATA_DIR, TOKEN_FILENAME);
if (existsSync(tokenPath)) {
const token = readFileSync(tokenPath, 'utf-8').trim();
if (token.length >= 32) {
cachedToken = token;
return token;
}
}
// Generate new 32-byte hex token
const token = randomBytes(32).toString('hex');
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(tokenPath, token, { mode: 0o600 });
cachedToken = token;
return token;
}
+3 -8
View File
@@ -4,7 +4,6 @@ import { logger } from "../utils/logger.js";
import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js";
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
import { MARKETPLACE_ROOT } from "./paths.js";
import { getAuthToken } from "./auth-token.js";
// Named constants for health checks
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000)
@@ -113,13 +112,9 @@ export function workerHttpRequest(
const url = buildWorkerUrl(apiPath);
const init: RequestInit = { method };
// Inject bearer token for worker API auth (#1932/#1933)
// Merge caller headers first, then set Authorization last to prevent override
const authHeaders: Record<string, string> = {
...options.headers,
'Authorization': `Bearer ${getAuthToken()}`
};
init.headers = authHeaders;
if (options.headers) {
init.headers = options.headers;
}
if (options.body) {
init.body = options.body;
}
+2 -3
View File
@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { authFetch } from '../utils/api';
// Log levels and components matching the logger.ts definitions
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
@@ -134,7 +133,7 @@ export function LogsDrawer({ isOpen, onClose }: LogsDrawerProps) {
setIsLoading(true);
setError(null);
try {
const response = await authFetch('/api/logs');
const response = await fetch('/api/logs');
if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.statusText}`);
}
@@ -159,7 +158,7 @@ export function LogsDrawer({ isOpen, onClose }: LogsDrawerProps) {
setIsLoading(true);
setError(null);
try {
const response = await authFetch('/api/logs/clear', { method: 'POST' });
const response = await fetch('/api/logs/clear', { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to clear logs: ${response.statusText}`);
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import type { ProjectCatalog, Settings } from '../types';
import { authFetch } from '../utils/api';
interface UseContextPreviewResult {
preview: string;
@@ -40,7 +39,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
async function fetchProjects() {
let data: ProjectCatalog;
try {
const response = await authFetch('/api/projects');
const response = await fetch('/api/projects');
data = await response.json() as ProjectCatalog;
} catch (err: unknown) {
console.error('Failed to fetch projects:', err instanceof Error ? err.message : String(err));
@@ -101,7 +100,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
}
try {
const response = await authFetch(`/api/context/preview?${params}`);
const response = await fetch(`/api/context/preview?${params}`);
const text = await response.text();
if (response.ok) {
+1 -2
View File
@@ -2,7 +2,6 @@ import { useState, useCallback, useRef } from 'react';
import { Observation, Summary, UserPrompt } from '../types';
import { UI } from '../constants/ui';
import { API_ENDPOINTS } from '../constants/api';
import { authFetch } from '../utils/api';
interface PaginationState {
isLoading: boolean;
@@ -69,7 +68,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
params.append('platformSource', currentSource);
}
const response = await authFetch(`${endpoint}?${params}`);
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to load ${dataType}: ${response.statusText}`);
+14 -30
View File
@@ -3,7 +3,6 @@ import { Settings } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings';
import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing';
import { authFetch } from '../utils/api';
export function useSettings() {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
@@ -12,13 +11,8 @@ export function useSettings() {
useEffect(() => {
// Load initial settings
authFetch(API_ENDPOINTS.SETTINGS)
.then(async res => {
if (!res.ok) {
throw new Error(`Failed to load settings (${res.status})`);
}
return res.json();
})
fetch(API_ENDPOINTS.SETTINGS)
.then(res => res.json())
.then(data => {
// Use ?? (nullish coalescing) instead of || so that falsy values
// like '0', 'false', and '' from the backend are preserved.
@@ -66,30 +60,20 @@ export function useSettings() {
setIsSaving(true);
setSaveStatus('Saving...');
try {
const response = await authFetch(API_ENDPOINTS.SETTINGS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSettings)
});
const response = await fetch(API_ENDPOINTS.SETTINGS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSettings)
});
if (!response.ok) {
setSaveStatus(`✗ Error: ${response.status === 401 ? 'Unauthorized' : response.statusText}`);
setIsSaving(false);
return;
}
const result = await response.json();
const result = await response.json();
if (result.success) {
setSettings(newSettings);
setSaveStatus('✓ Saved');
setTimeout(() => setSaveStatus(''), TIMING.SAVE_STATUS_DISPLAY_DURATION_MS);
} else {
setSaveStatus(`✗ Error: ${result.error}`);
}
} catch (error) {
setSaveStatus(`✗ Error: ${error instanceof Error ? error.message : 'Network error'}`);
if (result.success) {
setSettings(newSettings);
setSaveStatus('✓ Saved');
setTimeout(() => setSaveStatus(''), TIMING.SAVE_STATUS_DISPLAY_DURATION_MS);
} else {
setSaveStatus(`✗ Error: ${result.error}`);
}
setIsSaving(false);
+1 -2
View File
@@ -1,14 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { Stats } from '../types';
import { API_ENDPOINTS } from '../constants/api';
import { authFetch } from '../utils/api';
export function useStats() {
const [stats, setStats] = useState<Stats>({});
const loadStats = useCallback(async () => {
try {
const response = await authFetch(API_ENDPOINTS.STATS);
const response = await fetch(API_ENDPOINTS.STATS);
const data = await response.json();
setStats(data);
} catch (error: unknown) {
-22
View File
@@ -1,22 +0,0 @@
/**
* Authenticated fetch wrapper for viewer API calls.
* Reads the auth token injected into the page by the server (#1932/#1933).
*/
declare global {
interface Window {
__CLAUDE_MEM_AUTH_TOKEN__?: string;
}
}
export function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = window.__CLAUDE_MEM_AUTH_TOKEN__;
if (!token) {
return fetch(input, init);
}
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers });
}