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

View File

@@ -10,7 +10,7 @@
"plugins": [ "plugins": [
{ {
"name": "claude-mem", "name": "claude-mem",
"version": "12.3.3", "version": "12.3.2",
"source": "./plugin", "source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions" "description": "Persistent memory system for Claude Code - context compression across sessions"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mem", "name": "claude-mem",
"version": "12.3.3", "version": "12.3.2",
"description": "Memory compression system for Claude Code - persist context across sessions", "description": "Memory compression system for Claude Code - persist context across sessions",
"author": { "author": {
"name": "Alex Newman" "name": "Alex Newman"

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mem", "name": "claude-mem",
"version": "12.3.3", "version": "12.3.2",
"description": "Memory compression system for Claude Code - persist context across sessions", "description": "Memory compression system for Claude Code - persist context across sessions",
"author": { "author": {
"name": "Alex Newman", "name": "Alex Newman",

View File

@@ -6,68 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## ##
✅ CHANGELOG.md generated successfully! ✅ CHANGELOG.md generated successfully!
239 new release(s) prepended 237 new release(s) prepended
oks, security, and search e resolves error handling anti-patterns across the entire codebase (91 files), improving resilience and correctness.
### Security Hardening
- Bearer token authentication for all worker API endpoints with auto-generated tokens
- Path traversal protection on context write paths
- Per-user worker port derivation (37700 + uid%100) to prevent cross-user data leakage
- Rate limiting (300 req/min/IP) and reduced JSON body limit (50MB → 5MB)
- Caller headers can no longer override the bearer auth token
### Worker Stability
- Time-windowed RestartGuard replaces flat counter — prevents stranding pending messages on long sessions
- Idle session eviction prevents pool slot deadlock when all slots are full
- MCP loopback self-check uses process.execPath instead of bare 'node'
- Age-scoped failed message purge (1h retention) instead of clearing all
- RestartGuard decay anchored to real successes, not object creation time
### Search & Chroma
- FTS5 keyword fallback when ChromaDB is unavailable for all search handlers
- doc_type:'observation' filter on Chroma queries feeding observation hydration
- Project filtering passed to Chroma queries and SQLite hydration in all endpoints
- Bounded post-import Chroma sync with concurrency limit of 8
- FTS5 MATCH input escaped as quoted literal phrases to prevent syntax errors
- LIKE metacharacters escaped in prompt text search
- date_desc ordering respected in FTS session search
### Hooks Reliability
- Summarize hook wrapped in try/catch to prevent exit code 2 on network failures
- Session-init gated on health check success — no longer runs when worker unreachable
- Health-check wait loop added to UserPromptSubmit for Linux/WSL startup race
### Database & Performance
- Periodic WAL checkpoint and journal_size_limit to prevent unbounded WAL growth
- FTS5 availability cached at construction time (no DDL probe per query)
- _fts5Available downgraded when FTS table creation fails
### Viewer UI
- response.ok check added to settings save and initial load flows
- Auth failure handling in saveSettings
## [12.3.2] - 2026-04-20
## Bug Fixes
- **Search**: Fix `concept`/`concepts` parameter mismatch in `/api/search/by-concept` (#1916)
- **Search**: Add FTS5 keyword fallback when ChromaDB is unavailable (#1913, #2048)
- **Database**: Add periodic `clearFailed()` to purge stale pending messages (#1957)
- **Database**: Add WAL checkpoint schedule and `journal_size_limit` to prevent unbounded growth (#1956)
- **Worker**: Mark messages as failed (with retry) instead of confirming on non-XML responses (#1874)
- **Worker**: Include `activeSessions` in `/health` endpoint for queue liveness monitoring (#1867)
- **Docker**: Fix nounset-safe `TTY_ARGS` expansion in `run.sh`
- **Search**: Cache `isFts5Available()` at construction time (Greptile review)
## Closed Issues
#1908, #1953, #1916, #1913, #2048, #1957, #1956, #1874, #1867
## [12.3.1] - 2026-04-20
## Error Handling & Code Quality
This patch release resolves error handling anti-patterns across the entire codebase (91 files), improving resilience and correctness.
### Bug Fixes ### Bug Fixes

View File

@@ -1,228 +0,0 @@
# Issue Blowout 2026 - Running TODO
Branch: `issue-blowout-2026` (merged as PR #2079)
Strategy: Cynical dev. Every bug report is suspect — look for overengineered band-aids as root cause.
Test gate: After every build-and-sync, verify observations are flowing.
Released: **v12.3.2** on 2026-04-19
## Instructions for Continuation
### Workflow per issue
1. Use `/make-plan` and `/do` to attack each issue's root cause
2. Be cynical — most bug reports are surface-level; the real issue is usually overengineered band-aids
3. After every `npm run build-and-sync`, verify observations flow:
```bash
sleep 5 && sqlite3 ~/.claude-mem/claude-mem.db "SELECT COUNT(*) FROM observations WHERE created_at_epoch > (strftime('%s','now') - 120) * 1000"
```
4. If observations stop flowing, that's a regression — fix it before continuing
### Docker isolation
- **Port 37777**: Host's live bun worker (YOUR claude-mem instance — don't touch)
- **Port 37778**: Another agent's docker container (`claude-mem-dev`) — hands off
- **Your docker**: Use tag `claude-mem:blowout`, data dir `.docker-blowout-data/`
```bash
TAG=claude-mem:blowout docker/claude-mem/build.sh
HOST_MEM_DIR=$(pwd)/.docker-blowout-data TAG=claude-mem:blowout docker/claude-mem/run.sh
```
- Check observations in docker DB:
```bash
sqlite3 .docker-blowout-data/claude-mem.db 'select count(*) from observations'
```
### PR → Review → Merge → Release cycle
1. Create PR from feature branch to main
2. Start review loop: `/loop 2m` to check and resolve review comments
- CodeRabbit and Greptile post inline comments — read, fix, commit, push, reply
- `claude-review` is a CI check — just needs to pass
- CodeRabbit can take 5-10 min to process after each push
3. When all reviews pass: `gh pr merge <PR#> --repo thedotmack/claude-mem --squash --delete-branch --admin`
4. Close resolved issues: `for issue in <numbers>; do gh issue close $issue --repo thedotmack/claude-mem --comment "Fixed in PR #XXXX"; done`
5. Version bump:
```bash
cd ~/Scripts/claude-mem
git pull origin main
# Run /version-bump patch (or use the skill: claude-mem:version-bump)
# It handles: version files → build → commit → tag → push → gh release → changelog
```
### Key files in the codebase
- **Parser**: `src/sdk/parser.ts` — observation and summary XML parsing
- **Prompts**: `src/sdk/prompts.ts` — LLM prompt templates (observation, summary, continuation)
- **ResponseProcessor**: `src/services/worker/agents/ResponseProcessor.ts` — unified response handler
- **SessionManager**: `src/services/worker/SessionManager.ts` — queue, sessions, circuit breaker
- **SessionSearch**: `src/services/sqlite/SessionSearch.ts` — FTS5 and filter queries
- **SearchManager**: `src/services/worker/SearchManager.ts` — hybrid Chroma+SQLite orchestration
- **Worker service**: `src/services/worker-service.ts` — periodic reapers, startup
- **Summarize hook**: `src/cli/handlers/summarize.ts` — Stop hook entry point
- **SessionRoutes**: `src/services/worker/http/routes/SessionRoutes.ts` — HTTP API
- **ViewerRoutes**: `src/services/worker/http/routes/ViewerRoutes.ts` — /health endpoint
- **Agents**: `src/services/worker/SDKAgent.ts`, `GeminiAgent.ts`, `OpenRouterAgent.ts`
- **Modes**: `plugin/modes/code.json` — prompt field values for the default mode
- **Migrations**: `src/services/sqlite/migrations/runner.ts`
- **PendingMessageStore**: `src/services/sqlite/PendingMessageStore.ts` — queue persistence
## Completed Phase 2-5 (16 more issues — this session)
| # | Component | Issue | Resolution |
|---|-----------|-------|------------|
| 2053 | worker | Generator restart guard strands pending messages | FIXED — Time-windowed RestartGuard replaces flat counter (10 restarts/60s window, 5min decay) |
| 1868 | worker | SDK pool deadlock: idle sessions monopolize slots | FIXED — evictIdlestSession() callback in waitForSlot() preempts idle sessions |
| 1876 | worker | MCP loopback self-check fails; crash misclassification | FIXED — process.execPath replaces bare 'node'; removed false "exited unexpectedly" log |
| 1901 | hooks | Summarize stop hook exits code 2 on errors | FIXED — workerHttpRequest wrapped in try/catch, exits gracefully |
| 1907 | hooks | Linux/WSL session-init before worker healthy | FIXED — health-check curl loop added to UserPromptSubmit hook; HTTP call wrapped |
| 1896 | hooks | PreToolUse file-context caps Read to limit:1 | CLOSED — already fixed (mtime comparison at file-context.ts:255-267) |
| 1903 | hooks | PostToolUse/Stop/SessionEnd never fire | CLOSED — no-repro (hooks.json correct; Claude Code 12.0.1 platform bug) |
| 1932 | security | Admin endpoints spoofable requireLocalhost | FIXED — bearer token auth on all API endpoints |
| 1933 | security | Unauthenticated HTTP API exposes 30+ endpoints | FIXED — auto-generated token at ~/.claude-mem/worker-auth-token (mode 0600) |
| 1934 | security | watch.context.path written without validation | FIXED — path traversal protection validates against project root / data dir |
| 1935 | security | Unbounded input, no rate limits | FIXED — 5MB body limit (was 50MB), 300 req/min/IP rate limiter |
| 1936 | security | Multi-user macOS shared port cross-user MCP | FIXED — per-user port derivation from UID (37700 + uid%100) |
| 1911 | search | search()/timeline() cross-project results | FIXED — project filter passed to Chroma queries and timeline anchor searches |
| 1912 | search | /api/search per-type endpoints ignore project | FIXED — project $or clause added to searchObservations/Sessions/UserPrompts |
| 1914 | search | Imported observations invisible to MCP search | FIXED — ChromaSync.syncObservation() called after import |
| 1918 | search | SessionStart "no previous sessions" on fresh sessions | FIXED — session-init cwd fallback matches context.ts (process.cwd()) |
## Completed (9 issues — PR #2079, v12.3.2)
| # | Component | Issue | Resolution |
|---|-----------|-------|------------|
| 1908 | summarizer | parseSummary discards output when LLM emits observation tags | CLOSED — already fixed by Gen 3 coercion (coerceObservationToSummary in parser.ts) |
| 1953 | db | Migration 7 rebuilds table every startup | CLOSED — already fixed by commit 59ce0fc5 (origin !== 'pk' filter) |
| 1916 | search | /api/search/by-concept emits malformed SQL | FIXED — concept→concepts remap in SearchManager.normalizeParams() |
| 1913 | search | Text search returns empty when ChromaDB disabled | FIXED — FTS5 keyword fallback in SessionSearch + SearchManager |
| 2048 | search | Text queries should fall back to FTS5 when Chroma disabled | FIXED — same as #1913 |
| 1957 | db | pending_messages: failed rows never purged | FIXED — periodic clearFailed() in stale session reaper (every 2 min) |
| 1956 | db | WAL grows unbounded, no checkpoint schedule | FIXED — journal_size_limit=4MB + periodic wal_checkpoint(PASSIVE) |
| 1874 | worker | processAgentResponse deletes queued messages on non-XML output | FIXED — mark messages failed (with retry) instead of confirming |
| 1867 | worker | Queue processor dies while /health stays green | FIXED — activeSessions count added to /health endpoint |
Also fixed (not an issue): docker/claude-mem/run.sh nounset-safe TTY_ARGS expansion.
Also fixed (Greptile review): cached isFts5Available() at construction time.
## Remaining — CRITICAL (5)
| # | Component | Issue |
|---|-----------|-------|
| 1925 | mcp | chroma-mcp subprocess leak via null-before-close |
| 1926 | mcp | chroma-mcp stdio handshake broken across all versions |
| 1942 | auth | Default model not resolved on Bedrock/Vertex/Azure |
| 1943 | auth | SDK pipeline rejects Bedrock auth |
| 1880 | windows | Ghost LISTEN socket on port 37777 after crash |
| 1887 | windows | Failing worker blocks Claude Code MCP 10+ min in hook-restart loop |
## Remaining — HIGH (32)
| # | Component | Issue |
|---|-----------|-------|
| 1869 | worker | No mid-session auto-restart after inner crash |
| 1870 | worker | Stop hook blocks ~110s when SDK pool saturated |
| 1871 | worker | generateContext opens fresh SessionStore per call |
| 1875 | worker | Spawns uvx/node/claude by bare name; silent fail in non-interactive |
| 1877 | worker | Cross-session context bleed in same project dir |
| 1879 | worker | Session completion races in-flight summarize |
| 1890 | sdk-pool | SDK session resume during summarize causes context-overflow |
| 1892 | sdk-pool | Memory agent prompt defeats cache (dynamic before static) |
| 1895 | hooks | Stop hook spins 110s when worker older than v12.1.0 |
| 1897 | hooks | PreToolUse:Read lacks PATH export and cache-path lookup |
| 1899 | hooks | SessionStart additionalContext >10KB truncated to 2KB |
| 1902 | hooks | Stop and PostToolUse hooks synchronously block up to 120s |
| 1904 | hooks | UserPromptSubmit hooks skipped in git worktree sessions |
| 1905 | hooks | Saved_hook_context entries pegs CPU 100% on session load |
| 1906 | hooks | PR #1229 fallback path points to source, not cache |
| 1909 | summarizer | Summarize hook doesn't recognize Gemini transcripts |
| 1921 | mcp | Root .mcp.json is empty, mcp-search never registers |
| 1922 | mcp | MCP server uses 3s timeout for corpus prime/query |
| 1929 | installer | "Update now" fails for cache-only installs |
| 1930 | installer | Windows 11 ships smart-explore without tree-sitter |
| 1937 | observer | JSONL files accumulate indefinitely, tens of GB |
| 1938 | observer | Observer background sessions burn tokens with no budget |
| 1939 | cross-platform | Project key uses basename(cwd), fragmenting worktrees |
| 1941 | cross-platform | Linux worker with live-but-unhealthy PID blocks restart |
| 1944 | auth | ANTHROPIC_AUTH_TOKEN not forwarded to SDK subprocess |
| 1945 | auth | Vertex AI CLI auth fails silently on expired OAuth |
| 1947 | plugin-lifecycle | OpenCode tool args as plain objects not Zod schemas |
| 1948 | plugin-lifecycle | OpenClaw installer "plugin not found" |
| 1949 | plugin-lifecycle | OpenClaw per-agent memory isolation broken |
| 1950 | plugin-lifecycle | OpenClaw missing skills, session drift, workspaceDir loss |
| 1952 | db | ON UPDATE CASCADE rewrites historical session attribution |
| 1954 | db | observation_feedback schema mismatch source vs compiled |
| 1958 | viewer | Settings model dropdown destroys precise model IDs |
| 1881-1888 | windows | 8 Windows-specific bugs (paths, spawning, timeouts) |
## Remaining — MEDIUM (21)
| # | Component | Issue |
|---|-----------|-------|
| 1872 | worker | Gemini 400/401 triggers 2-min crash-recovery loop |
| 1873 | worker | worker-service.cjs killed by SIGKILL (unbounded heap) |
| 1878 | worker | Logger caches log file path, never rotates |
| 1891 | sdk-pool | Mode prompts in user messages, not system prompt |
| 1893 | sdk-pool | SDK sub-agents hardcoded permissionMode:"default" |
| 1894 | hooks | SessionStart can't find claude at ~/.local/bin |
| 1898 | hooks | SessionStart health-check uses hardcoded port 37777 |
| 1900 | hooks | Setup hook references non-existent scripts/setup.sh |
| 1910 | summarizer | Summary prompt leaks observation tags, ignores user_prompt |
| 1915 | search | Search results not deduplicated |
| 1917 | search | $CMEM context preview shows oldest instead of newest |
| 1920 | search | Context footer "ID" ambiguous across 3 ID spaces |
| 1923 | mcp | smart_outline empty for .txt files |
| 1924 | mcp | chroma-mcp child not terminated on exit |
| 1927 | mcp | chroma-mcp fails on WSL with ALL_PROXY=socks5 |
| 1928 | installer | BranchManager.pullUpdates() fails on cache-layout |
| 1931 | installer | npm run worker:status ENOENT .claude/package.json |
| 1940 | cross-platform | cmux.app wrapper "Claude executable not found" |
| 1946 | auth | OpenRouter 401 Missing Authentication header |
| 1955 | db | Duplicate observations bypass content-hash dedup |
| 1959 | viewer | SSE new_prompt broadcast dies after /reload-plugins |
| 1961 | misc | Traditional Chinese falls back to Simplified |
## Remaining — LOW (3)
| # | Component | Issue |
|---|-----------|-------|
| 1919 | search | Shared jsts tree-sitter query applies TS-only to JS |
| 1951 | plugin-lifecycle | OpenClaw lifecycle events stored as observations |
| 1960 | misc | OpenRouter URL hardcoded |
## Remaining — NON-LABELED (1)
| # | Component | Issue |
|---|-----------|-------|
| 2054 | installer | installCLI version-pinned alias can't self-update |
## Suggested Next Attack Order
### Phase 2: Worker stability — DONE
### Phase 3: Hooks reliability — DONE
### Phase 4: Security hardening — DONE
### Phase 5: Search remaining — DONE
### Phase 6: MCP + Auth
- #1925, #1926, #1942, #1943
### Phase 7: Windows
- #1880, #1887, #1881-1888
### Phase 6: MCP / Chroma
- #1925, #1926, #2046, #1921
### Phase 7: Everything else
- Remaining hooks, installer, windows, observer, viewer, auth, plugin-lifecycle
## Progress Log
| Time | Action | Result |
|------|--------|--------|
| 9:40p | #1908 analyzed | Already fixed by Gen 3 coercion. Closed. |
| 9:51p | #1916 fixed | concept→concepts remap in normalizeParams |
| 9:53p | #1913/#2048 fixed | FTS5 fallback in SessionSearch + SearchManager |
| 9:57p | #1953 closed | Already fixed by commit 59ce0fc5 |
| 9:57p | #1957 fixed | Periodic clearFailed() in stale session reaper |
| 9:58p | #1956 fixed | journal_size_limit + periodic WAL checkpoint |
| 10:01p | #1874 fixed | Non-XML responses mark messages failed instead of confirming |
| 10:01p | #1867 fixed | Health endpoint includes activeSessions count |
| 10:02p | build-and-sync | Observations flowing. No regression. |
| 10:03p | PR #2079 created | 2 commits pushed |
| 10:06p | Greptile review | 2 comments — cached isFts5Available(). Fixed + pushed. |
| 10:20p | PR #2079 merged | All reviews passed (CodeRabbit, Greptile, claude-review) |
| 10:25p | v12.3.2 released | Tag pushed, GitHub release created, CHANGELOG updated |

View File

@@ -56,7 +56,7 @@ else
fi fi
# Pick -it only when a TTY is attached (keeps non-interactive callers working). # Pick -it only when a TTY is attached (keeps non-interactive callers working).
# Initialize empty; expansion below safely omits args when the array is unset/empty. # Initialize with a no-op flag so the array is never empty (nounset-safe).
TTY_ARGS=() TTY_ARGS=()
[[ -t 0 && -t 1 ]] && TTY_ARGS=(-it) [[ -t 0 && -t 1 ]] && TTY_ARGS=(-it)

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mem", "name": "claude-mem",
"version": "12.3.3", "version": "12.3.2",
"description": "Memory compression system for Claude Code - persist context across sessions", "description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [ "keywords": [
"claude", "claude",

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mem", "name": "claude-mem",
"version": "12.3.3", "version": "12.3.2",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions", "description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": { "author": {
"name": "Alex Newman" "name": "Alex Newman"

View File

@@ -24,12 +24,12 @@
}, },
{ {
"type": "command", "type": "command",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 || true; echo '{\"continue\":true,\"suppressOutput\":true}'", "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; curl -sf http://localhost:37777/health >/dev/null 2>&1 || true; echo '{\"continue\":true,\"suppressOutput\":true}'",
"timeout": 60 "timeout": 60
}, },
{ {
"type": "command", "type": "command",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1; then node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context || true; fi", "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do curl -sf http://localhost:37777/health >/dev/null 2>&1 && break; sleep 1; done; if curl -sf http://localhost:37777/health >/dev/null 2>&1; then node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context || true; fi",
"timeout": 60 "timeout": 60
} }
] ]
@@ -40,7 +40,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; _HEALTH=0; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && _HEALTH=1 || for i in 1 2 3 4 5 6 7 8 9 10; do sleep 1; curl -sf http://localhost:$((37700 + $(id -u 2>/dev/null || echo 77) % 100))/health >/dev/null 2>&1 && _HEALTH=1 && break; done; [ \"$_HEALTH\" = \"1\" ] && node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init", "command": "export PATH=\"$($SHELL -lc 'echo $PATH' 2>/dev/null):$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"timeout": 60 "timeout": 60
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mem-plugin", "name": "claude-mem-plugin",
"version": "12.3.3", "version": "12.3.2",
"private": true, "private": true,
"description": "Runtime dependencies for claude-mem bundled hooks", "description": "Runtime dependencies for claude-mem bundled hooks",
"type": "module", "type": "module",

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

File diff suppressed because one or more lines are too long

View File

@@ -108,22 +108,12 @@ try {
// Trigger worker restart after file sync // Trigger worker restart after file sync
console.log('\n🔄 Triggering worker restart...'); console.log('\n🔄 Triggering worker restart...');
const http = require('http'); const http = require('http');
const fs = require('fs');
const os = require('os');
// Read auth token for API auth (#1932/#1933)
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || require('path').join(os.homedir(), '.claude-mem');
let authToken = '';
try { authToken = fs.readFileSync(require('path').join(dataDir, 'worker-auth-token'), 'utf-8').trim(); } catch {}
// Use per-user port derivation (#1936)
const uid = typeof process.getuid === 'function' ? process.getuid() : 77;
const workerPort = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || String(37700 + (uid % 100)), 10);
const req = http.request({ const req = http.request({
hostname: '127.0.0.1', hostname: '127.0.0.1',
port: workerPort, port: 37777,
path: '/api/admin/restart', path: '/api/admin/restart',
method: 'POST', method: 'POST',
timeout: 2000, timeout: 2000
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
}, (res) => { }, (res) => {
if (res.statusCode === 200) { if (res.statusCode === 200) {
console.log('\x1b[32m%s\x1b[0m', '✓ Worker restart triggered'); console.log('\x1b[32m%s\x1b[0m', '✓ Worker restart triggered');

View File

@@ -44,8 +44,7 @@ export const sessionInitHandler: EventHandler = {
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
} }
const { sessionId, prompt: rawPrompt } = input; const { sessionId, cwd, prompt: rawPrompt } = input;
const cwd = input.cwd ?? process.cwd(); // Match context.ts fallback (#1918)
// Guard: Codex CLI and other platforms may not provide a session_id (#744) // Guard: Codex CLI and other platforms may not provide a session_id (#744)
if (!sessionId) { if (!sessionId) {
@@ -70,23 +69,16 @@ export const sessionInitHandler: EventHandler = {
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project }); logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
// Initialize session via HTTP - handles DB operations and privacy checks // Initialize session via HTTP - handles DB operations and privacy checks
let initResponse: Response; const initResponse = await workerHttpRequest('/api/sessions/init', {
try { method: 'POST',
initResponse = await workerHttpRequest('/api/sessions/init', { headers: { 'Content-Type': 'application/json' },
method: 'POST', body: JSON.stringify({
headers: { 'Content-Type': 'application/json' }, contentSessionId: sessionId,
body: JSON.stringify({ project,
contentSessionId: sessionId, prompt,
project, platformSource
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 };
}
if (!initResponse.ok) { if (!initResponse.ok) {
// Log but don't throw - a worker 500 should not block the user's prompt // Log but don't throw - a worker 500 should not block the user's prompt

View File

@@ -84,24 +84,16 @@ export const summarizeHandler: EventHandler = {
const platformSource = normalizePlatformSource(input.platform); const platformSource = normalizePlatformSource(input.platform);
// 1. Queue summarize request — worker returns immediately with { status: 'queued' } // 1. Queue summarize request — worker returns immediately with { status: 'queued' }
let response: Response; const response = await workerHttpRequest('/api/sessions/summarize', {
try { method: 'POST',
response = await workerHttpRequest('/api/sessions/summarize', { headers: { 'Content-Type': 'application/json' },
method: 'POST', body: JSON.stringify({
headers: { 'Content-Type': 'application/json' }, contentSessionId: sessionId,
body: JSON.stringify({ last_assistant_message: lastAssistantMessage,
contentSessionId: sessionId, platformSource
last_assistant_message: lastAssistantMessage, }),
platformSource timeoutMs: SUMMARIZE_TIMEOUT_MS
}), });
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 };
}
if (!response.ok) { if (!response.ok) {
return { continue: true, suppressOutput: true }; return { continue: true, suppressOutput: true };

View File

@@ -97,37 +97,6 @@ interface SessionDeletedEvent {
const WORKER_BASE_URL = "http://127.0.0.1:37777"; const WORKER_BASE_URL = "http://127.0.0.1:37777";
const MAX_TOOL_RESPONSE_LENGTH = 1000; 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 // Worker HTTP Client
// ============================================================================ // ============================================================================
@@ -140,7 +109,7 @@ async function workerPost(
try { try {
response = await fetch(`${WORKER_BASE_URL}${path}`, { response = await fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST", method: "POST",
headers: getAuthHeaders(), headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} catch (error: unknown) { } catch (error: unknown) {
@@ -165,7 +134,7 @@ function workerPostFireAndForget(
): void { ): void {
fetch(`${WORKER_BASE_URL}${path}`, { fetch(`${WORKER_BASE_URL}${path}`, {
method: "POST", method: "POST",
headers: getAuthHeaders(), headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}).catch((error: unknown) => { }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -177,7 +146,7 @@ function workerPostFireAndForget(
async function workerGetText(path: string): Promise<string | null> { async function workerGetText(path: string): Promise<string | null> {
try { try {
const response = await fetch(`${WORKER_BASE_URL}${path}`, { headers: getAuthHeaders() }); const response = await fetch(`${WORKER_BASE_URL}${path}`);
if (!response.ok) { if (!response.ok) {
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`); console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
return null; return null;

View File

@@ -10,6 +10,5 @@
export { export {
createMiddleware, createMiddleware,
requireLocalhost, requireLocalhost,
requireAuth,
summarizeRequestBody summarizeRequestBody
} from '../worker/http/middleware.js'; } from '../worker/http/middleware.js';

View File

@@ -15,7 +15,7 @@ import * as fs from 'fs';
import path from 'path'; import path from 'path';
import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js'; import { ALLOWED_OPERATIONS, ALLOWED_TOPICS } from './allowed-constants.js';
import { logger } from '../../utils/logger.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 { errorHandler, notFoundHandler } from './ErrorHandler.js';
import { getSupervisor } from '../../supervisor/index.js'; import { getSupervisor } from '../../supervisor/index.js';
import { isPidAlive } from '../../supervisor/process-registry.js'; import { isPidAlive } from '../../supervisor/process-registry.js';
@@ -155,14 +155,6 @@ export class Server {
private setupMiddleware(): void { private setupMiddleware(): void {
const middlewares = createMiddleware(summarizeRequestBody); const middlewares = createMiddleware(summarizeRequestBody);
middlewares.forEach(mw => this.app.use(mw)); 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);
});
} }
/** /**

View File

@@ -477,25 +477,6 @@ export class PendingMessageStore {
return result.changes; 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 * Clear all pending, processing, and failed messages from the queue
* Keeps only processed messages (for history) * Keeps only processed messages (for history)

View File

@@ -36,7 +36,7 @@ export class SessionSearch {
// Cache FTS5 availability once at construction (avoids DDL probe on every query) // Cache FTS5 availability once at construction (avoids DDL probe on every query)
this._fts5Available = this.isFts5Available(); this._fts5Available = this.isFts5Available();
// Ensure FTS tables exist — may downgrade _fts5Available if creation fails // Ensure FTS tables exist
this.ensureFTSTables(); this.ensureFTSTables();
} }
@@ -84,7 +84,6 @@ export class SessionSearch {
logger.info('DB', 'FTS5 tables created successfully'); logger.info('DB', 'FTS5 tables created successfully');
} catch (error) { } catch (error) {
// FTS5 creation failed at runtime despite probe succeeding — degrade gracefully // 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); 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 ? LIMIT ? OFFSET ?
`; `;
// Escape FTS5 special characters: wrap in quotes to treat as literal phrase params.unshift(query);
const escapedQuery = '"' + query.replace(/"/g, '""') + '"';
params.unshift(escapedQuery);
params.push(limit, offset); params.push(limit, offset);
try { try {
return this.db.prepare(sql).all(...params) as ObservationSearchResult[]; return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
} catch (error) { } catch (error) {
// Re-throw so callers can distinguish FTS failure from "no results" logger.warn('DB', 'FTS5 observation search failed, returning empty', {}, error instanceof Error ? error : undefined);
logger.warn('DB', 'FTS5 observation search failed', {}, error instanceof Error ? error : undefined); return [];
throw error;
} }
} }
@@ -387,9 +383,7 @@ export class SessionSearch {
const orderClause = orderBy === 'date_asc' const orderClause = orderBy === 'date_asc'
? 'ORDER BY s.created_at_epoch ASC' ? 'ORDER BY s.created_at_epoch ASC'
: orderBy === 'date_desc' : 'ORDER BY session_summaries_fts.rank ASC';
? 'ORDER BY s.created_at_epoch DESC'
: 'ORDER BY session_summaries_fts.rank ASC';
const sql = ` const sql = `
SELECT s.*, s.discovery_tokens SELECT s.*, s.discovery_tokens
@@ -401,17 +395,14 @@ export class SessionSearch {
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`; `;
// Escape FTS5 special characters: wrap in quotes to treat as literal phrase params.unshift(query);
const escapedQuery = '"' + query.replace(/"/g, '""') + '"';
params.unshift(escapedQuery);
params.push(limit, offset); params.push(limit, offset);
try { try {
return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[]; return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[];
} catch (error) { } catch (error) {
// Re-throw so callers can distinguish FTS failure from "no results" logger.warn('DB', 'FTS5 session search failed, returning empty', {}, error instanceof Error ? error : undefined);
logger.warn('DB', 'FTS5 session search failed', {}, error instanceof Error ? error : undefined); return [];
throw error;
} }
} }
@@ -654,10 +645,8 @@ export class SessionSearch {
} }
// LIKE fallback for user prompts text search (no FTS table for this entity) // 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 baseConditions.push('up.prompt_text LIKE ?');
const escapedQuery = query.replace(/[\\%_]/g, '\\$&'); params.push(`%${query}%`);
baseConditions.push("up.prompt_text LIKE ? ESCAPE '\\'");
params.push(`%${escapedQuery}%`);
const whereClause = `WHERE ${baseConditions.join(' AND ')}`; const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
const orderClause = orderBy === 'date_asc' const orderClause = orderBy === 'date_asc'

View File

@@ -1,10 +1,8 @@
import path from 'path';
import { sessionInitHandler } from '../../cli/handlers/session-init.js'; import { sessionInitHandler } from '../../cli/handlers/session-init.js';
import { observationHandler } from '../../cli/handlers/observation.js'; import { observationHandler } from '../../cli/handlers/observation.js';
import { fileEditHandler } from '../../cli/handlers/file-edit.js'; import { fileEditHandler } from '../../cli/handlers/file-edit.js';
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js'; import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js'; import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { DATA_DIR } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { getProjectContext } from '../../utils/project-name.js'; import { getProjectContext } from '../../utils/project-name.js';
import { writeAgentsMd } from '../../utils/agents-md-utils.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 contextUrl = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`;
const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`); 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>>; let response: Awaited<ReturnType<typeof workerHttpRequest>>;
try { try {
response = await workerHttpRequest(contextUrl); response = await workerHttpRequest(contextUrl);

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 // ensure the worker daemon is up without importing this entire module — which
// transitively pulls in the SQLite database layer via ChromaSync/DatabaseManager. // transitively pulls in the SQLite database layer via ChromaSync/DatabaseManager.
import { ensureWorkerStarted as ensureWorkerStartedShared } from './worker-spawner.js'; 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 // Re-export for backward compatibility — canonical implementation in shared/plugin-state.ts
export { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js'; export { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
@@ -483,7 +482,7 @@ export class WorkerService {
// Best-effort loopback MCP self-check // Best-effort loopback MCP self-check
getSupervisor().assertCanSpawn('mcp server'); getSupervisor().assertCanSpawn('mcp server');
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: process.execPath, // Use resolved path, not bare 'node' which fails on non-interactive PATH (#1876) command: 'node',
args: [mcpServerPath], args: [mcpServerPath],
env: sanitizeEnv(process.env) env: sanitizeEnv(process.env)
}); });
@@ -559,14 +558,12 @@ export class WorkerService {
} }
} }
// Purge stale failed pending messages to prevent unbounded queue growth (#1957) // Purge failed pending messages to prevent unbounded queue growth (#1957)
// Only remove failures older than 1 hour to preserve recent failures for inspection/retry
try { try {
const pendingStore = this.sessionManager.getPendingMessageStore(); const pendingStore = this.sessionManager.getPendingMessageStore();
const FAILED_MESSAGE_RETENTION_MS = 60 * 60 * 1000; // 1 hour const purged = pendingStore.clearFailed();
const purged = pendingStore.clearFailedOlderThan(FAILED_MESSAGE_RETENTION_MS);
if (purged > 0) { 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) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
@@ -819,19 +816,17 @@ export class WorkerService {
} }
// Fall through to pending-work restart below // Fall through to pending-work restart below
} }
if (pendingCount > 0) { const MAX_PENDING_RESTARTS = 3;
// 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
if (!restartAllowed) { if (pendingCount > 0) {
logger.error('SYSTEM', 'Restart guard tripped: too many restarts in window, stopping to prevent runaway costs', { // 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, sessionId: session.sessionDbId,
pendingCount, pendingCount,
restartsInWindow: session.restartGuard.restartsInWindow, consecutiveRestarts: session.consecutiveRestarts
windowMs: session.restartGuard.windowMs,
maxRestarts: session.restartGuard.maxRestarts
}); });
session.consecutiveRestarts = 0; session.consecutiveRestarts = 0;
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded'); this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
@@ -851,7 +846,6 @@ export class WorkerService {
} else { } else {
// Successful completion with no pending work — clean up session // Successful completion with no pending work — clean up session
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus() // removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
session.restartGuard?.recordSuccess();
session.consecutiveRestarts = 0; session.consecutiveRestarts = 0;
this.sessionManager.removeSessionImmediate(session.sessionDbId); this.sessionManager.removeSessionImmediate(session.sessionDbId);
} }

View File

@@ -3,7 +3,6 @@
*/ */
import type { Response } from 'express'; import type { Response } from 'express';
import type { RestartGuard } from './worker/RestartGuard.js';
// ============================================================================ // ============================================================================
// Active Session Types // Active Session Types
@@ -35,8 +34,7 @@ export interface ActiveSession {
earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps) earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps)
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
consecutiveRestarts: number; // DEPRECATED: use restartGuard. Kept for logging compat. consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
restartGuard?: RestartGuard;
forceInit?: boolean; // Force fresh SDK session (skip resume) forceInit?: boolean; // Force fresh SDK session (skip resume)
idleTimedOut?: boolean; // Set when session exits due to idle timeout (prevents restart loop) 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) lastGeneratorActivity: number; // Timestamp of last generator progress (for stale detection, Issue #1099)

View File

@@ -115,15 +115,10 @@ function notifySlotAvailable(): void {
* Wait for a pool slot to become available (promise-based, not polling) * Wait for a pool slot to become available (promise-based, not polling)
* @param maxConcurrent Max number of concurrent agents * @param maxConcurrent Max number of concurrent agents
* @param timeoutMs Max time to wait before giving up * @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; const TOTAL_PROCESS_HARD_CAP = 10;
export async function waitForSlot( export async function waitForSlot(maxConcurrent: number, timeoutMs: number = 60_000): Promise<void> {
maxConcurrent: number,
timeoutMs: number = 60_000,
evictIdleSession?: () => boolean
): Promise<void> {
// Hard cap: refuse to spawn if too many processes exist regardless of pool accounting // Hard cap: refuse to spawn if too many processes exist regardless of pool accounting
const activeCount = getActiveCount(); const activeCount = getActiveCount();
if (activeCount >= TOTAL_PROCESS_HARD_CAP) { if (activeCount >= TOTAL_PROCESS_HARD_CAP) {
@@ -132,17 +127,6 @@ export async function waitForSlot(
if (activeCount < maxConcurrent) return; 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...`); logger.info('PROCESS', `Pool limit reached (${activeCount}/${maxConcurrent}), waiting for slot...`);
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {

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;
}
}

View File

@@ -90,11 +90,9 @@ export class SDKAgent {
} }
// Wait for agent pool slot (configurable via CLAUDE_MEM_MAX_CONCURRENT_AGENTS) // 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 settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2; const maxConcurrent = parseInt(settings.CLAUDE_MEM_MAX_CONCURRENT_AGENTS, 10) || 2;
await waitForSlot(maxConcurrent, 60_000, () => this.sessionManager.evictIdlestSession()); await waitForSlot(maxConcurrent);
// Build isolated environment from ~/.claude-mem/.env // Build isolated environment from ~/.claude-mem/.env
// This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files // This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files

View File

@@ -67,20 +67,8 @@ export class SearchManager {
return await this.chromaSync.queryChroma(query, limit, whereFilter); return await this.chromaSync.queryChroma(query, limit, whereFilter);
} }
private async searchChromaForTimeline(query: string, ninetyDaysAgo: number, project?: string): Promise<ObservationSearchResult[]> { private async searchChromaForTimeline(query: string, ninetyDaysAgo: number): Promise<ObservationSearchResult[]> {
// Build where filter scoped to observations only + project if provided const chromaResults = await this.queryChroma(query, 100);
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);
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 }); logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
if (chromaResults?.ids && chromaResults.ids.length > 0) { if (chromaResults?.ids && chromaResults.ids.length > 0) {
@@ -90,7 +78,7 @@ export class SearchManager {
}); });
if (recentIds.length > 0) { 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 []; return [];
@@ -298,20 +286,14 @@ export class SearchManager {
// ChromaDB not initialized - fall back to FTS5 keyword search (#1913, #2048) // ChromaDB not initialized - fall back to FTS5 keyword search (#1913, #2048)
else if (query) { else if (query) {
logger.debug('SEARCH', 'ChromaDB not initialized — falling back to FTS5 keyword search', {}); logger.debug('SEARCH', 'ChromaDB not initialized — falling back to FTS5 keyword search', {});
try { if (searchObservations) {
if (searchObservations) { observations = this.sessionSearch.searchObservations(query, { ...options, type: obs_type, concepts, files });
observations = this.sessionSearch.searchObservations(query, { ...options, type: obs_type, concepts, files }); }
} if (searchSessions) {
if (searchSessions) { sessions = this.sessionSearch.searchSessions(query, options);
sessions = this.sessionSearch.searchSessions(query, options); }
} if (searchPrompts) {
if (searchPrompts) { prompts = this.sessionSearch.searchUserPrompts(query, options);
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;
} }
} }
@@ -487,25 +469,13 @@ export class SearchManager {
logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {}); logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {});
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
try { try {
results = await this.searchChromaForTimeline(query, ninetyDaysAgo, project); results = await this.searchChromaForTimeline(query, ninetyDaysAgo);
} catch (chromaError) { } catch (chromaError) {
const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError)); const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError));
logger.error('WORKER', 'Chroma search failed for timeline, continuing without semantic results', {}, errorObject); 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) { if (results.length === 0) {
return { return {
content: [{ content: [{
@@ -957,55 +927,26 @@ export class SearchManager {
if (this.chromaSync) { if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search (Chroma + SQLite)', {}); 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) // Step 1: Chroma semantic search (top 100)
try { const chromaResults = await this.queryChroma(query, 100);
const chromaResults = await this.queryChroma(query, 100, whereFilter); logger.debug('SEARCH', 'Chroma returned semantic matches', { matchCount: chromaResults.ids.length });
logger.debug('SEARCH', 'Chroma returned semantic matches', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => { const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; 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 // Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) { if (recentIds.length > 0) {
const limit = options.limit || 20; const limit = options.limit || 20;
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project }); results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length }); 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) { if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for sessions', {}); 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) // Step 1: Chroma semantic search (top 100)
try { const chromaResults = await this.queryChroma(query, 100, { doc_type: 'session_summary' });
const chromaResults = await this.queryChroma(query, 100, whereFilter); logger.debug('SEARCH', 'Chroma returned semantic matches for sessions', { matchCount: chromaResults.ids.length });
logger.debug('SEARCH', 'Chroma returned semantic matches for sessions', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => { const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; 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 // Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) { if (recentIds.length > 0) {
const limit = options.limit || 20; const limit = options.limit || 20;
results = this.sessionStore.getSessionSummariesByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project }); results = this.sessionStore.getSessionSummariesByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated sessions from SQLite', { count: results.length }); 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) { if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for user prompts', {}); 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) // Step 1: Chroma semantic search (top 100)
try { const chromaResults = await this.queryChroma(query, 100, { doc_type: 'user_prompt' });
const chromaResults = await this.queryChroma(query, 100, whereFilter); logger.debug('SEARCH', 'Chroma returned semantic matches for prompts', { matchCount: chromaResults.ids.length });
logger.debug('SEARCH', 'Chroma returned semantic matches for prompts', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => { const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; 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 // Step 3: Hydrate from SQLite in temporal order
if (recentIds.length > 0) { if (recentIds.length > 0) {
const limit = options.limit || 20; const limit = options.limit || 20;
results = this.sessionStore.getUserPromptsByIds(recentIds, { orderBy: 'date_desc', limit, project: options.project }); results = this.sessionStore.getUserPromptsByIds(recentIds, { orderBy: 'date_desc', limit });
logger.debug('SEARCH', 'Hydrated user prompts from SQLite', { count: results.length }); 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 // Use hybrid search if available
if (this.chromaSync) { if (this.chromaSync) {
logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {}); 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 if (chromaResults.ids.length > 0) {
let whereFilter: Record<string, any> = { doc_type: 'observation' }; // Filter by recency (90 days)
if (project) { const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
const projectFilter = { const recentIds = chromaResults.ids.filter((_id, idx) => {
$or: [ const meta = chromaResults.metadatas[idx];
{ project }, return meta && meta.created_at_epoch > ninetyDaysAgo;
{ merged_into_project: project } });
]
};
whereFilter = { $and: [whereFilter, projectFilter] };
}
try { logger.debug('SEARCH', 'Results within 90-day window', { count: recentIds.length });
const chromaResults = await this.queryChroma(query, 100, whereFilter);
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults.ids.length });
if (chromaResults.ids.length > 0) { if (recentIds.length > 0) {
// Filter by recency (90 days) results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: mode === 'auto' ? 1 : limit });
const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; logger.debug('SEARCH', 'Hydrated observations from SQLite', { count: results.length });
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 });
}
} }
} 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);
} }
} }

View File

@@ -17,7 +17,6 @@ import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js'; import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
import { getSupervisor } from '../../supervisor/index.js'; import { getSupervisor } from '../../supervisor/index.js';
import { MAX_CONSECUTIVE_SUMMARY_FAILURES } from '../../sdk/prompts.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. */ /** Idle threshold before a stuck generator (zombie subprocess) is force-killed. */
export const MAX_GENERATOR_IDLE_MS = 5 * 60 * 1000; // 5 minutes export const MAX_GENERATOR_IDLE_MS = 5 * 60 * 1000; // 5 minutes
@@ -225,8 +224,7 @@ export class SessionManager {
earliestPendingTimestamp: null, earliestPendingTimestamp: null,
conversationHistory: [], // Initialize empty - will be populated by agents conversationHistory: [], // Initialize empty - will be populated by agents
currentProvider: null, // Will be set when generator starts currentProvider: null, // Will be set when generator starts
consecutiveRestarts: 0, // DEPRECATED: use restartGuard. Kept for logging compat. consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
restartGuard: new RestartGuard(),
processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed() processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
lastGeneratorActivity: Date.now(), // Initialize for stale detection (Issue #1099) lastGeneratorActivity: Date.now(), // Initialize for stale detection (Issue #1099)
consecutiveSummaryFailures: 0, // Circuit breaker for summary retry loop (#1633) 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. * 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 * Also reaps sessions whose generator has been stuck (no lastGeneratorActivity update) for

View File

@@ -207,8 +207,6 @@ export async function processAgentResponse(
} }
if (session.processingMessageIds.length > 0) { if (session.processingMessageIds.length > 0) {
logger.debug('QUEUE', `CONFIRMED_BATCH | sessionDbId=${session.sessionDbId} | count=${session.processingMessageIds.length} | ids=[${session.processingMessageIds.join(',')}]`); 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 // Clear the tracking array after confirmation
session.processingMessageIds = []; session.processingMessageIds = [];

View File

@@ -9,7 +9,6 @@ import express, { Request, Response, NextFunction, RequestHandler } from 'expres
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import path from 'path';
import { getPackageRoot } from '../../../shared/paths.js'; import { getPackageRoot } from '../../../shared/paths.js';
import { getAuthToken } from '../../../shared/auth-token.js';
import { logger } from '../../../utils/logger.js'; import { logger } from '../../../utils/logger.js';
/** /**
@@ -22,8 +21,8 @@ export function createMiddleware(
): RequestHandler[] { ): RequestHandler[] {
const middlewares: RequestHandler[] = []; const middlewares: RequestHandler[] = [];
// JSON parsing with 5mb limit (#1935) // JSON parsing with 50mb limit
middlewares.push(express.json({ limit: '5mb' })); middlewares.push(express.json({ limit: '50mb' }));
// CORS - restrict to localhost origins only // CORS - restrict to localhost origins only
middlewares.push(cors({ middlewares.push(cors({
@@ -43,39 +42,6 @@ export function createMiddleware(
credentials: false 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 // HTTP request/response logging
middlewares.push((req: Request, res: Response, next: NextFunction) => { middlewares.push((req: Request, res: Response, next: NextFunction) => {
// Skip logging for static assets, health checks, and polling endpoints // Skip logging for static assets, health checks, and polling endpoints
@@ -140,27 +106,6 @@ export function requireLocalhost(req: Request, res: Response, next: NextFunction
next(); 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 * Summarize request body for logging
* Used to avoid logging sensitive data or large payloads * Used to avoid logging sensitive data or large payloads

View File

@@ -382,13 +382,11 @@ export class DataRoutes extends BaseRouteHandler {
} }
// Import observations (depends on sessions) // Import observations (depends on sessions)
const importedObservations: Array<{ id: number; obs: typeof observations[0] }> = [];
if (Array.isArray(observations)) { if (Array.isArray(observations)) {
for (const obs of observations) { for (const obs of observations) {
const result = store.importObservation(obs); const result = store.importObservation(obs);
if (result.imported) { if (result.imported) {
stats.observationsImported++; stats.observationsImported++;
importedObservations.push({ id: result.id, obs });
} else { } else {
stats.observationsSkipped++; stats.observationsSkipped++;
} }
@@ -400,53 +398,6 @@ export class DataRoutes extends BaseRouteHandler {
if (stats.observationsImported > 0) { if (stats.observationsImported > 0) {
store.rebuildObservationsFTSIndex(); 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) // Import prompts (depends on sessions)

View File

@@ -24,7 +24,6 @@ import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js'; import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
import { getProjectContext } from '../../../../utils/project-name.js'; import { getProjectContext } from '../../../../utils/project-name.js';
import { normalizePlatformSource } from '../../../../shared/platform-source.js'; import { normalizePlatformSource } from '../../../../shared/platform-source.js';
import { RestartGuard } from '../../RestartGuard.js';
export class SessionRoutes extends BaseRouteHandler { export class SessionRoutes extends BaseRouteHandler {
private completionHandler: SessionCompletionHandler; private completionHandler: SessionCompletionHandler;
@@ -280,10 +279,9 @@ export class SessionRoutes extends BaseRouteHandler {
if (wasAborted) { if (wasAborted) {
logger.info('SESSION', `Generator aborted`, { sessionId: sessionDbId }); 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.generatorPromise = null;
session.currentProvider = 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) // Crash recovery: If not aborted and still has work, restart (with limit)
if (!wasAborted) { if (!wasAborted) {
const pendingStore = this.sessionManager.getPendingMessageStore(); const pendingStore = this.sessionManager.getPendingMessageStore();
const MAX_CONSECUTIVE_RESTARTS = 3;
let pendingCount: number; let pendingCount: number;
try { try {
@@ -310,18 +309,14 @@ export class SessionRoutes extends BaseRouteHandler {
return; return;
} }
// Windowed restart guard: only blocks tight-loop restarts, not spread-out ones (#2053) session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
if (!session.restartGuard) session.restartGuard = new RestartGuard();
const restartAllowed = session.restartGuard.recordRestart();
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1; // Keep for logging
if (!restartAllowed) { if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) {
logger.error('SESSION', `CRITICAL: Restart guard tripped — too many restarts in window, stopping to prevent runaway costs`, { logger.error('SESSION', `CRITICAL: Generator restart limit exceeded - stopping to prevent runaway costs`, {
sessionId: sessionDbId, sessionId: sessionDbId,
pendingCount, pendingCount,
restartsInWindow: session.restartGuard.restartsInWindow, consecutiveRestarts: session.consecutiveRestarts,
windowMs: session.restartGuard.windowMs, maxRestarts: MAX_CONSECUTIVE_RESTARTS,
maxRestarts: session.restartGuard.maxRestarts,
action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.' action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.'
}); });
// Don't restart - abort to prevent further API calls // Don't restart - abort to prevent further API calls
@@ -333,8 +328,7 @@ export class SessionRoutes extends BaseRouteHandler {
sessionId: sessionDbId, sessionId: sessionDbId,
pendingCount, pendingCount,
consecutiveRestarts: session.consecutiveRestarts, consecutiveRestarts: session.consecutiveRestarts,
restartsInWindow: session.restartGuard!.restartsInWindow, maxRestarts: MAX_CONSECUTIVE_RESTARTS
maxRestarts: session.restartGuard!.maxRestarts
}); });
// Abort OLD controller before replacing to prevent child process leaks // Abort OLD controller before replacing to prevent child process leaks

View File

@@ -10,7 +10,6 @@ import path from 'path';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { logger } from '../../../../utils/logger.js'; import { logger } from '../../../../utils/logger.js';
import { getPackageRoot } from '../../../../shared/paths.js'; import { getPackageRoot } from '../../../../shared/paths.js';
import { getAuthToken } from '../../../../shared/auth-token.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js'; import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import { DatabaseManager } from '../../DatabaseManager.js'; import { DatabaseManager } from '../../DatabaseManager.js';
import { SessionManager } from '../../SessionManager.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'); throw new Error('Viewer UI not found at any expected location');
} }
let html = readFileSync(viewerPath, 'utf-8'); const 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>`);
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
res.send(html); res.send(html);
}); });

View File

@@ -85,7 +85,7 @@ export class SettingsDefaultsManager {
private static readonly DEFAULTS: SettingsDefaults = { private static readonly DEFAULTS: SettingsDefaults = {
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6', CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50', 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_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion', CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
// AI Provider Configuration // AI Provider Configuration

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;
}

View File

@@ -4,7 +4,6 @@ import { logger } from "../utils/logger.js";
import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js"; import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js";
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js"; import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
import { MARKETPLACE_ROOT } from "./paths.js"; import { MARKETPLACE_ROOT } from "./paths.js";
import { getAuthToken } from "./auth-token.js";
// Named constants for health checks // Named constants for health checks
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000) // 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 url = buildWorkerUrl(apiPath);
const init: RequestInit = { method }; const init: RequestInit = { method };
// Inject bearer token for worker API auth (#1932/#1933) if (options.headers) {
// Merge caller headers first, then set Authorization last to prevent override init.headers = options.headers;
const authHeaders: Record<string, string> = { }
...options.headers,
'Authorization': `Bearer ${getAuthToken()}`
};
init.headers = authHeaders;
if (options.body) { if (options.body) {
init.body = options.body; init.body = options.body;
} }

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { authFetch } from '../utils/api';
// Log levels and components matching the logger.ts definitions // Log levels and components matching the logger.ts definitions
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
@@ -134,7 +133,7 @@ export function LogsDrawer({ isOpen, onClose }: LogsDrawerProps) {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await authFetch('/api/logs'); const response = await fetch('/api/logs');
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch logs: ${response.statusText}`); throw new Error(`Failed to fetch logs: ${response.statusText}`);
} }
@@ -159,7 +158,7 @@ export function LogsDrawer({ isOpen, onClose }: LogsDrawerProps) {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await authFetch('/api/logs/clear', { method: 'POST' }); const response = await fetch('/api/logs/clear', { method: 'POST' });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to clear logs: ${response.statusText}`); throw new Error(`Failed to clear logs: ${response.statusText}`);
} }

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { ProjectCatalog, Settings } from '../types'; import type { ProjectCatalog, Settings } from '../types';
import { authFetch } from '../utils/api';
interface UseContextPreviewResult { interface UseContextPreviewResult {
preview: string; preview: string;
@@ -40,7 +39,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
async function fetchProjects() { async function fetchProjects() {
let data: ProjectCatalog; let data: ProjectCatalog;
try { try {
const response = await authFetch('/api/projects'); const response = await fetch('/api/projects');
data = await response.json() as ProjectCatalog; data = await response.json() as ProjectCatalog;
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to fetch projects:', err instanceof Error ? err.message : String(err)); console.error('Failed to fetch projects:', err instanceof Error ? err.message : String(err));
@@ -101,7 +100,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
} }
try { try {
const response = await authFetch(`/api/context/preview?${params}`); const response = await fetch(`/api/context/preview?${params}`);
const text = await response.text(); const text = await response.text();
if (response.ok) { if (response.ok) {

View File

@@ -2,7 +2,6 @@ import { useState, useCallback, useRef } from 'react';
import { Observation, Summary, UserPrompt } from '../types'; import { Observation, Summary, UserPrompt } from '../types';
import { UI } from '../constants/ui'; import { UI } from '../constants/ui';
import { API_ENDPOINTS } from '../constants/api'; import { API_ENDPOINTS } from '../constants/api';
import { authFetch } from '../utils/api';
interface PaginationState { interface PaginationState {
isLoading: boolean; isLoading: boolean;
@@ -69,7 +68,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
params.append('platformSource', currentSource); params.append('platformSource', currentSource);
} }
const response = await authFetch(`${endpoint}?${params}`); const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load ${dataType}: ${response.statusText}`); throw new Error(`Failed to load ${dataType}: ${response.statusText}`);

View File

@@ -3,7 +3,6 @@ import { Settings } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings'; import { DEFAULT_SETTINGS } from '../constants/settings';
import { API_ENDPOINTS } from '../constants/api'; import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing'; import { TIMING } from '../constants/timing';
import { authFetch } from '../utils/api';
export function useSettings() { export function useSettings() {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS); const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
@@ -12,13 +11,8 @@ export function useSettings() {
useEffect(() => { useEffect(() => {
// Load initial settings // Load initial settings
authFetch(API_ENDPOINTS.SETTINGS) fetch(API_ENDPOINTS.SETTINGS)
.then(async res => { .then(res => res.json())
if (!res.ok) {
throw new Error(`Failed to load settings (${res.status})`);
}
return res.json();
})
.then(data => { .then(data => {
// Use ?? (nullish coalescing) instead of || so that falsy values // Use ?? (nullish coalescing) instead of || so that falsy values
// like '0', 'false', and '' from the backend are preserved. // like '0', 'false', and '' from the backend are preserved.
@@ -66,30 +60,20 @@ export function useSettings() {
setIsSaving(true); setIsSaving(true);
setSaveStatus('Saving...'); setSaveStatus('Saving...');
try { const response = await fetch(API_ENDPOINTS.SETTINGS, {
const response = await authFetch(API_ENDPOINTS.SETTINGS, { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newSettings)
body: JSON.stringify(newSettings) });
});
if (!response.ok) { const result = await response.json();
setSaveStatus(`✗ Error: ${response.status === 401 ? 'Unauthorized' : response.statusText}`);
setIsSaving(false);
return;
}
const result = await response.json(); if (result.success) {
setSettings(newSettings);
if (result.success) { setSaveStatus('✓ Saved');
setSettings(newSettings); setTimeout(() => setSaveStatus(''), TIMING.SAVE_STATUS_DISPLAY_DURATION_MS);
setSaveStatus('✓ Saved'); } else {
setTimeout(() => setSaveStatus(''), TIMING.SAVE_STATUS_DISPLAY_DURATION_MS); setSaveStatus(`✗ Error: ${result.error}`);
} else {
setSaveStatus(`✗ Error: ${result.error}`);
}
} catch (error) {
setSaveStatus(`✗ Error: ${error instanceof Error ? error.message : 'Network error'}`);
} }
setIsSaving(false); setIsSaving(false);

View File

@@ -1,14 +1,13 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Stats } from '../types'; import { Stats } from '../types';
import { API_ENDPOINTS } from '../constants/api'; import { API_ENDPOINTS } from '../constants/api';
import { authFetch } from '../utils/api';
export function useStats() { export function useStats() {
const [stats, setStats] = useState<Stats>({}); const [stats, setStats] = useState<Stats>({});
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
try { try {
const response = await authFetch(API_ENDPOINTS.STATS); const response = await fetch(API_ENDPOINTS.STATS);
const data = await response.json(); const data = await response.json();
setStats(data); setStats(data);
} catch (error: unknown) { } catch (error: unknown) {

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 });
}