feat: security observation types + Telegram notifier (#2084)
* feat: security observation types + Telegram notifier Adds two severity-axis security observation types (security_alert, security_note) to the code mode and a fire-and-forget Telegram notifier that posts when a saved observation matches configured type or concept triggers. Default trigger fires on security_alert only; notifier is disabled until BOT_TOKEN and CHAT_ID are set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(telegram): honor CLAUDE_MEM_TELEGRAM_ENABLED master toggle Adds an explicit on/off flag (default 'true') so users can disable the notifier without clearing credentials. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(stop-hook): make summarize handler fire-and-forget Stop hook previously blocked the Claude Code session for up to 110 seconds while polling the worker for summary completion. The handler now returns as soon as the enqueue POST is acked. - summarize.ts: drop the 500ms polling loop and /api/sessions/complete call; tighten SUMMARIZE_TIMEOUT_MS from 300s to 5s since the worker acks the enqueue synchronously. - SessionCompletionHandler: extract idempotent finalizeSession() for DB mark + orphaned-pending-queue drain + broadcast. completeByDbId now delegates so the /api/sessions/complete HTTP route is backward compatible. - SessionRoutes: wire finalizeSession into the SDK-agent generator's finally block, gated on lastSummaryStored + empty pending queue so only Stop events produce finalize (not every idle tick). - WorkerService: own the single SessionCompletionHandler instance and inject it into SessionRoutes to avoid duplicate construction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr2084): address reviewer findings CodeRabbit: - SessionStore.getSessionById now returns status; without it, the finalizeSession idempotency guard always evaluated false and re-fired drain/broadcast on every call. - worker-service.ts: three call sites that remove the in-memory session after finalizeSession now do so only on success. On failure the session is left in place so the 60s orphan reaper can retry; removing it would orphan an 'active' DB row indefinitely under the fire-and- forget Stop hook. - runFallbackForTerminatedSession no longer emits a second session_completed event; finalizeSession already broadcasts one. The explicit broadcast now runs only on the finalize-failure fallback. Greptile: - TelegramNotifier reads via loadFromFile(USER_SETTINGS_PATH) so values in ~/.claude-mem/settings.json actually take effect; SettingsDefaultsManager.get() alone skipped the file and silently ignored user-configured credentials. - Emoji is derived from obs.type (security_alert → 🚨, security_note → 🔐, fallback 🔔) instead of hardcoded 🚨 for every observation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): worker-port mismatch on Windows and settings.json overrides (#2086) Hooks computed the health-check port as \$((37700 + id -u % 100)), ignoring ~/.claude-mem/settings.json. Two failure modes resulted: 1. Users upgrading from pre-per-uid builds kept CLAUDE_MEM_WORKER_PORT set to '37777' in settings.json. The worker bound 37777 (settings wins), but hooks queried 37701 (uid 501 on macOS), so every SessionStart/UserPromptSubmit health check failed. 2. Windows Git Bash/PowerShell returns a real Windows UID for 'id -u' (e.g. 209), producing port 37709 while the Node worker fell back to 37777 (process.getuid?.() ?? 77). Every prompt hit the 60s hook timeout. hooks.json now resolves the port in this order, matching how the worker itself resolves it: 1. sed CLAUDE_MEM_WORKER_PORT from ~/.claude-mem/settings.json 2. If absent, and uname is MINGW/CYGWIN/MSYS → 37777 3. Otherwise 37700 + (id -u || 77) % 100 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr2084): sync DatabaseManager.getSessionById return type CodeRabbit round 2: the DatabaseManager.getSessionById return type was missing platform_source, custom_title, and status fields that SessionStore.getSessionById actually returns. Structural typing hid the mismatch at compile time, but it prevents callers going through DatabaseManager from seeing the status field that the idempotency guard in SessionCompletionHandler relies on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr2084): hooks honor env vars and host; looser port regex (#2086 followup) CodeRabbit round 3: match the worker's env > file > defaults precedence and resolve host the same way as port. - Env: CLAUDE_MEM_WORKER_PORT and CLAUDE_MEM_WORKER_HOST win first. - File: sed now accepts both quoted ('"37777"') and unquoted (37777) JSON values for the port; a separate sed reads CLAUDE_MEM_WORKER_HOST. - Defaults: port per-uid formula (Windows: 37777), host 127.0.0.1. - Health-check URL uses the resolved $HOST instead of hardcoded localhost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,7 @@ import { SearchManager } from './worker/SearchManager.js';
|
||||
import { FormattingService } from './worker/FormattingService.js';
|
||||
import { TimelineService } from './worker/TimelineService.js';
|
||||
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
||||
import { SessionCompletionHandler } from './worker/session/SessionCompletionHandler.js';
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './transcripts/config.js';
|
||||
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||
|
||||
@@ -152,6 +153,7 @@ export class WorkerService {
|
||||
private paginationHelper: PaginationHelper;
|
||||
private settingsManager: SettingsManager;
|
||||
private sessionEventBroadcaster: SessionEventBroadcaster;
|
||||
private sessionCompletionHandler: SessionCompletionHandler;
|
||||
private corpusStore: CorpusStore;
|
||||
|
||||
// Route handlers
|
||||
@@ -198,6 +200,11 @@ export class WorkerService {
|
||||
this.paginationHelper = new PaginationHelper(this.dbManager);
|
||||
this.settingsManager = new SettingsManager(this.dbManager);
|
||||
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
|
||||
this.sessionCompletionHandler = new SessionCompletionHandler(
|
||||
this.sessionManager,
|
||||
this.sessionEventBroadcaster,
|
||||
this.dbManager
|
||||
);
|
||||
this.corpusStore = new CorpusStore();
|
||||
|
||||
// Set callback for when sessions are deleted
|
||||
@@ -305,7 +312,7 @@ export class WorkerService {
|
||||
|
||||
// Standard routes (registered AFTER guard middleware)
|
||||
this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager));
|
||||
this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this));
|
||||
this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this, this.sessionCompletionHandler));
|
||||
this.server.registerRoutes(new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime));
|
||||
this.server.registerRoutes(new SettingsRoutes(this.settingsManager));
|
||||
this.server.registerRoutes(new LogsRoutes());
|
||||
@@ -849,11 +856,26 @@ export class WorkerService {
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
this.broadcastProcessingStatus();
|
||||
} else {
|
||||
// Successful completion with no pending work — clean up session
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
// Successful completion with no pending work — clean up session.
|
||||
// Only remove from the in-memory map if finalize succeeds; otherwise
|
||||
// leave the session in place so the 60s orphan reaper (or a future
|
||||
// retry) can repair the inconsistency. Removing a still-"active" DB
|
||||
// row from memory would orphan it indefinitely under the new
|
||||
// fire-and-forget Stop hook (no /api/sessions/complete to retry).
|
||||
session.restartGuard?.recordSuccess();
|
||||
session.consecutiveRestarts = 0;
|
||||
this.sessionManager.removeSessionImmediate(session.sessionDbId);
|
||||
let finalized = false;
|
||||
try {
|
||||
this.sessionCompletionHandler.finalizeSession(session.sessionDbId);
|
||||
finalized = true;
|
||||
} catch (err) {
|
||||
logger.warn('SESSION', 'finalizeSession failed in WorkerService generator .finally()', {
|
||||
sessionId: session.sessionDbId
|
||||
}, err as Error);
|
||||
}
|
||||
if (finalized) {
|
||||
this.sessionManager.removeSessionImmediate(session.sessionDbId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -947,8 +969,25 @@ export class WorkerService {
|
||||
abandoned
|
||||
});
|
||||
}
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
// Finalize so DB status + broadcast + pending-drain are consistent on fallback failure.
|
||||
// finalizeSession already broadcasts session_completed, so we don't also call
|
||||
// broadcastSessionCompleted below. On finalize failure, fall back to the
|
||||
// explicit broadcast so the UI still gets the event and leave the session
|
||||
// in memory for the orphan reaper to retry.
|
||||
let finalized = false;
|
||||
try {
|
||||
this.sessionCompletionHandler.finalizeSession(sessionDbId);
|
||||
finalized = true;
|
||||
} catch (err) {
|
||||
logger.warn('SESSION', 'finalizeSession failed in runFallbackForTerminatedSession', {
|
||||
sessionId: sessionDbId
|
||||
}, err as Error);
|
||||
}
|
||||
if (finalized) {
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
} else {
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -971,8 +1010,25 @@ export class WorkerService {
|
||||
abandonedMessages: abandoned
|
||||
});
|
||||
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
// Finalize session (mark completed in DB + drain pending + broadcast). Idempotent.
|
||||
// This runs AFTER startSession() has returned, which means any summary/observation
|
||||
// writes inside processAgentResponse() are already committed to SQLite synchronously.
|
||||
// Only remove from the in-memory map if finalize succeeds; otherwise leave the
|
||||
// session in place so the 60s orphan reaper can repair the DB inconsistency.
|
||||
let finalized = false;
|
||||
try {
|
||||
this.sessionCompletionHandler.finalizeSession(sessionDbId);
|
||||
finalized = true;
|
||||
} catch (err) {
|
||||
logger.warn('SESSION', 'finalizeSession failed during terminateSession', {
|
||||
sessionId: sessionDbId, reason
|
||||
}, err as Error);
|
||||
}
|
||||
|
||||
if (finalized) {
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user