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:
Alex Newman
2026-04-22 16:08:28 -07:00
committed by GitHub
parent 49ab404c08
commit f2d361b918
18 changed files with 560 additions and 333 deletions
+64 -8
View File
@@ -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);
}
}
/**