Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd1fe5995f | |||
| 6791069bca | |||
| 3e6add90de | |||
| d3331d1e22 | |||
| bd619229b2 | |||
| 182097ef1c | |||
| 0b7ecedcd7 | |||
| da01e4bba0 | |||
| 7c3bfadd5e | |||
| a8bb625513 | |||
| bab8f554bd | |||
| c1b5b2a783 | |||
| 67651669a1 | |||
| ae454cfc01 | |||
| fa218b0d71 | |||
| c29d91a9c4 | |||
| e6ae017609 | |||
| 901cff909e | |||
| 5c8e2dcfcc | |||
| 47dec9cf4d |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.5",
|
||||
"version": "9.0.11",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
+109
-97
@@ -2,6 +2,115 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v9.0.10] - 2026-01-26
|
||||
|
||||
## Bug Fix
|
||||
|
||||
**Fixed path format mismatch causing folder CLAUDE.md files to show "No recent activity" (#794)** - Thanks @bigph00t!
|
||||
|
||||
The folder-level CLAUDE.md generation was failing to find observations due to a path format mismatch between how API queries used absolute paths and how the database stored relative paths. The `isDirectChild()` function's simple prefix match always returned false in these cases.
|
||||
|
||||
**Root cause:** PR #809 (v9.0.9) only masked this bug by skipping file creation when "no activity" was detected. Since ALL folders were affected, this prevented file creation entirely. This PR provides the actual fix.
|
||||
|
||||
**Changes:**
|
||||
- Added new shared module `src/shared/path-utils.ts` with robust path normalization and matching utilities
|
||||
- Updated `SessionSearch.ts`, `regenerate-claude-md.ts`, and `claude-md-utils.ts` to use shared path utilities
|
||||
- Added comprehensive test coverage (61 new tests) for path matching edge cases
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v9.0.9] - 2026-01-26
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Prevent Creation of Empty CLAUDE.md Files (#809)
|
||||
|
||||
Previously, claude-mem would create new `CLAUDE.md` files in project directories even when there was no activity to display, cluttering codebases with empty context files showing only "*No recent activity*".
|
||||
|
||||
**What changed:** The `updateFolderClaudeMdFiles` function now checks if the formatted content contains no activity before writing. If a `CLAUDE.md` file doesn't already exist and there's nothing to show, it will be skipped entirely. Existing files will still be updated to reflect "No recent activity" if that's the current state.
|
||||
|
||||
**Impact:** Cleaner project directories - only folders with actual activity will have `CLAUDE.md` context files created.
|
||||
|
||||
Thanks to @maxmillienjr for this contribution!
|
||||
|
||||
## [v9.0.8] - 2026-01-26
|
||||
|
||||
## Fix: Prevent Zombie Process Accumulation (Issue #737)
|
||||
|
||||
This release fixes a critical issue where Claude haiku subprocesses spawned by the SDK weren't terminating properly, causing zombie process accumulation. One user reported 155 processes consuming 51GB RAM.
|
||||
|
||||
### Root Causes Addressed
|
||||
- SDK's SpawnedProcess interface hides subprocess PIDs
|
||||
- `deleteSession()` didn't verify subprocess exit
|
||||
- `abort()` was fire-and-forget with no confirmation
|
||||
- No mechanism to track or clean up orphaned processes
|
||||
|
||||
### Solution
|
||||
- **ProcessRegistry module**: Tracks spawned Claude subprocesses via PID
|
||||
- **Custom spawn**: Uses SDK's `spawnClaudeCodeProcess` option to capture PIDs
|
||||
- **Signal propagation**: Passes signal parameter to enable AbortController integration
|
||||
- **Graceful shutdown**: Waits for subprocess exit in `deleteSession()` with 5s timeout
|
||||
- **SIGKILL escalation**: Force-kills processes that don't exit gracefully
|
||||
- **Orphan reaper**: Safety net running every 5 minutes to clean up any missed processes
|
||||
- **Race detection**: Warns about multiple processes per session (race condition indicator)
|
||||
|
||||
### Files Changed
|
||||
- `src/services/worker/ProcessRegistry.ts` (new): PID registry and reaper
|
||||
- `src/services/worker/SDKAgent.ts`: Use custom spawn to capture PIDs
|
||||
- `src/services/worker/SessionManager.ts`: Verify subprocess exit on delete
|
||||
- `src/services/worker-service.ts`: Start/stop orphan reaper
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.7...v9.0.8
|
||||
|
||||
Fixes #737
|
||||
|
||||
## [v9.0.6] - 2026-01-22
|
||||
|
||||
## Windows Console Popup Fix
|
||||
|
||||
This release eliminates the annoying console window popups that Windows users experienced when claude-mem spawned background processes.
|
||||
|
||||
### Fixed
|
||||
- **Windows console popups eliminated** - Daemon spawn and Chroma operations no longer create visible console windows (#748, #708, #681, #676)
|
||||
- **Race condition in PID file writing** - Worker now writes its own PID file after listen() succeeds, ensuring reliable process tracking on all platforms
|
||||
|
||||
### Changed
|
||||
- **Chroma temporarily disabled on Windows** - Vector search is disabled on Windows while we migrate to a popup-free architecture. Keyword search and all other memory features continue to work. A follow-up release will re-enable Chroma.
|
||||
- **Slash command discoverability** - Added YAML frontmatter to `/do` and `/make-plan` commands
|
||||
|
||||
### Technical Details
|
||||
- Uses WMIC for detached process spawning on Windows
|
||||
- PID file location unchanged, but now written by worker process
|
||||
- Cross-platform: Linux/macOS behavior unchanged
|
||||
|
||||
### Contributors
|
||||
- @bigph00t (Alexander Knigge)
|
||||
|
||||
## [v9.0.5] - 2026-01-14
|
||||
|
||||
## Major Worker Service Cleanup
|
||||
|
||||
This release contains a significant refactoring of `worker-service.ts`, removing ~216 lines of dead code and simplifying the architecture.
|
||||
|
||||
### Refactoring
|
||||
- **Removed dead code**: Deleted `runInteractiveSetup` function (defined but never called)
|
||||
- **Cleaned up imports**: Removed unused imports (fs namespace, spawn, homedir, readline, existsSync, writeFileSync, readFileSync, mkdirSync)
|
||||
- **Removed fallback agent concept**: Users who choose Gemini/OpenRouter now get those providers directly without hidden fallback behavior
|
||||
- **Eliminated re-export indirection**: ResponseProcessor now imports directly from CursorHooksInstaller instead of through worker-service
|
||||
|
||||
### Security Fix
|
||||
- **Removed dangerous ANTHROPIC_API_KEY check**: Claude Code uses CLI authentication, not direct API calls. The previous check could accidentally use a user's API key (from other projects) which costs 20x more than Claude Code's pricing
|
||||
|
||||
### Build Improvements
|
||||
- **Dynamic MCP version management**: MCP server and client versions now use build-time injected values from package.json instead of hardcoded strings, ensuring version synchronization
|
||||
|
||||
### Documentation
|
||||
- Added Anti-Pattern Czar Generalization Analysis report
|
||||
- Updated README with $CMEM links and contract address
|
||||
- Added comprehensive cleanup and validation plans for worker-service.ts
|
||||
|
||||
## [v9.0.4] - 2026-01-10
|
||||
|
||||
## What's New
|
||||
@@ -1205,100 +1314,3 @@ This represents a major reliability improvement for Windows users, eliminating c
|
||||
|
||||
- Enhanced SDKAgent response handling and message processing
|
||||
|
||||
## [v7.3.5] - 2025-12-17
|
||||
|
||||
## What's Changed
|
||||
* fix(windows): solve zombie port problem with wrapper architecture by @ToxMox in https://github.com/thedotmack/claude-mem/pull/372
|
||||
* chore: bump version to 7.3.5 by @thedotmack in https://github.com/thedotmack/claude-mem/pull/375
|
||||
|
||||
## New Contributors
|
||||
* @ToxMox made their first contribution in https://github.com/thedotmack/claude-mem/pull/372
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.4...v7.3.5
|
||||
|
||||
## [v7.3.4] - 2025-12-17
|
||||
|
||||
Patch release for bug fixes and minor improvements
|
||||
|
||||
## [v7.3.3] - 2025-12-16
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Remove all better-sqlite3 references from codebase (#357)
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.2...v7.3.3
|
||||
|
||||
## [v7.3.2] - 2025-12-16
|
||||
|
||||
## 🪟 Windows Console Fix
|
||||
|
||||
Fixes blank console windows appearing for Windows 11 users during claude-mem operations.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Windows**: Uses PowerShell `Start-Process -WindowStyle Hidden` to properly hide worker process
|
||||
- **Security**: Added PowerShell string escaping to follow security best practices
|
||||
- **Unix/Mac**: No changes (continues to work as before)
|
||||
|
||||
### Root Cause
|
||||
|
||||
The issue was caused by a Node.js limitation where `windowsHide: true` doesn't work with `detached: true` in `child_process.spawn()`. This affects both Bun and Node.js since Bun inherits Node.js process spawning semantics.
|
||||
|
||||
See: https://github.com/nodejs/node/issues/21825
|
||||
|
||||
### Security Note
|
||||
|
||||
While all paths in the PowerShell command are application-controlled (not user input), we've added proper escaping to follow security best practices. If an attacker could modify bun installation paths or plugin directories, they would already have full filesystem access including the database.
|
||||
|
||||
### Related
|
||||
|
||||
- Fixes #304 (Multiple visible console windows)
|
||||
- Merged PR #339
|
||||
- Testing documented in PR #315
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None - fully backward compatible.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.1...v7.3.2
|
||||
|
||||
## [v7.3.1] - 2025-12-16
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
### Pending Messages Cleanup (Issue #353)
|
||||
|
||||
Fixed unbounded database growth in the `pending_messages` table by implementing proper cleanup logic:
|
||||
|
||||
- **Content Clearing**: `markProcessed()` now clears `tool_input` and `tool_response` when marking messages as processed, preventing duplicate storage of transcript data that's already saved in observations
|
||||
- **Count-Based Retention**: `cleanupProcessed()` now keeps only the 100 most recent processed messages for UI display, deleting older ones automatically
|
||||
- **Automatic Cleanup**: Cleanup runs automatically after processing messages in `SDKAgent.processSDKResponse()`
|
||||
|
||||
### What This Fixes
|
||||
|
||||
- Prevents database from growing unbounded with duplicate transcript content
|
||||
- Keeps metadata (tool_name, status, timestamps) for recent messages
|
||||
- Maintains UI functionality while optimizing storage
|
||||
|
||||
### Technical Details
|
||||
|
||||
**Files Modified:**
|
||||
- `src/services/sqlite/PendingMessageStore.ts` - Cleanup logic implementation
|
||||
- `src/services/worker/SDKAgent.ts` - Periodic cleanup calls
|
||||
|
||||
**Database Behavior:**
|
||||
- Pending/processing messages: Keep full transcript data (needed for processing)
|
||||
- Processed messages: Clear transcript, keep metadata only (observations already saved)
|
||||
- Retention: Last 100 processed messages for UI feedback
|
||||
|
||||
### Related
|
||||
|
||||
- Fixes #353 - Observations not being saved
|
||||
- Part of the pending messages persistence feature (from PR #335)
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.0...v7.3.1
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ See the [LICENSE](LICENSE) file for full details.
|
||||
- **Documentation**: [docs/](docs/)
|
||||
- **Issues**: [GitHub Issues](https://github.com/thedotmack/claude-mem/issues)
|
||||
- **Repository**: [github.com/thedotmack/claude-mem](https://github.com/thedotmack/claude-mem)
|
||||
- **Official X Account**: [@Claude_Memory](https://x.com/Claude_Memory)
|
||||
- **Official Discord**: [Join Discord](https://discord.com/invite/J4wttp9vDu)
|
||||
- **Author**: Alex Newman ([@thedotmack](https://github.com/thedotmack))
|
||||
|
||||
---
|
||||
|
||||
+16
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.5",
|
||||
"version": "9.0.11",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -26,6 +26,21 @@
|
||||
"url": "https://github.com/thedotmack/claude-mem/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./sdk": {
|
||||
"types": "./dist/sdk/index.d.ts",
|
||||
"import": "./dist/sdk/index.js"
|
||||
},
|
||||
"./modes/*": "./plugin/modes/*"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"plugin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"bun": ">=1.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.5",
|
||||
"version": "9.0.11",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
description: "Execute a plan using subagents for implementation"
|
||||
argument-hint: "[task or plan reference]"
|
||||
---
|
||||
|
||||
You are an ORCHESTRATOR.
|
||||
|
||||
Primary instruction: deploy subagents to execute *all* work for #$ARGUMENTS.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
description: "Create an implementation plan with documentation discovery"
|
||||
argument-hint: "[feature or task description]"
|
||||
---
|
||||
|
||||
You are an ORCHESTRATOR.
|
||||
|
||||
Create an LLM-friendly plan in phases that can be executed consecutively in new chat contexts.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "9.0.5",
|
||||
"version": "9.0.11",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+129
-128
File diff suppressed because one or more lines are too long
@@ -43,8 +43,10 @@ interface ObservationRow {
|
||||
discovery_tokens: number | null;
|
||||
}
|
||||
|
||||
// Import shared formatting utilities
|
||||
// Import shared utilities
|
||||
import { formatTime, groupByDate } from '../src/shared/timeline-formatting.js';
|
||||
import { isDirectChild } from '../src/shared/path-utils.js';
|
||||
import { replaceTaggedContent } from '../src/utils/claude-md-utils.js';
|
||||
|
||||
// Type icon map (matches ModeManager)
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
@@ -135,19 +137,6 @@ function walkDirectoriesWithIgnore(dir: string, folders: Set<string>, depth: num
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder)
|
||||
* @param filePath - File path like "src/services/foo.ts"
|
||||
* @param folderPath - Folder path like "src/services"
|
||||
* @returns true if file is directly in folder, false if in a subfolder
|
||||
*/
|
||||
function isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
if (!filePath.startsWith(folderPath + '/')) return false;
|
||||
const remainder = filePath.slice(folderPath.length + 1);
|
||||
// If remainder contains a slash, it's in a subfolder
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an observation has any files that are direct children of the folder
|
||||
*/
|
||||
@@ -288,37 +277,27 @@ function formatObservationsForClaudeMd(observations: ObservationRow[], folderPat
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file with tagged content preservation
|
||||
* Note: For the CLI regenerate tool, we DO create directories since the user
|
||||
* explicitly requested regeneration. This differs from the runtime behavior
|
||||
* which only writes to existing folders.
|
||||
*/
|
||||
function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
function writeClaudeMdToFolderForRegenerate(folderPath: string, newContent: string): void {
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const tempFile = `${claudeMdPath}.tmp`;
|
||||
|
||||
// For regenerate CLI, we create the folder if needed
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
// Read existing content if file exists
|
||||
let existingContent = '';
|
||||
if (existsSync(claudeMdPath)) {
|
||||
existingContent = readFileSync(claudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
const startTag = '<claude-mem-context>';
|
||||
const endTag = '</claude-mem-context>';
|
||||
|
||||
let finalContent: string;
|
||||
if (!existingContent) {
|
||||
finalContent = `${startTag}\n${newContent}\n${endTag}`;
|
||||
} else {
|
||||
const startIdx = existingContent.indexOf(startTag);
|
||||
const endIdx = existingContent.indexOf(endTag);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
finalContent = existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
} else {
|
||||
finalContent = existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`;
|
||||
}
|
||||
}
|
||||
// Use shared utility to preserve user content outside tags
|
||||
const finalContent = replaceTaggedContent(existingContent, newContent);
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
writeFileSync(tempFile, finalContent);
|
||||
renameSync(tempFile, claudeMdPath);
|
||||
}
|
||||
@@ -450,7 +429,7 @@ function regenerateFolder(
|
||||
|
||||
// Format using relative path for display, write to absolute path
|
||||
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
|
||||
writeClaudeMdToFolder(absoluteFolder, formatted);
|
||||
writeClaudeMdToFolderForRegenerate(absoluteFolder, formatted);
|
||||
|
||||
return { success: true, observationCount: observations.length };
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #40137 | 11:48 PM | ⚖️ | User Requests Reversion: Restore Token Creators to Bottom of Page | ~412 |
|
||||
| #40136 | " | 🟣 | Token Creators Added as Circular Avatars in Token Details Card | ~469 |
|
||||
| #40135 | 11:47 PM | 🔵 | Token Details Card Still Uses Creator Profile Picture as Fallback | ~397 |
|
||||
| #40134 | " | 🔄 | Reverted Token Creators From Title Bar Back to Original Ticker Layout | ~418 |
|
||||
| #40133 | " | 🔵 | User Clarification: Title Bar Refers to Token Details Card Component | ~376 |
|
||||
| #40132 | " | 🔵 | User Seeking Location Context: What Comes After Header Component | ~370 |
|
||||
| #40131 | " | 🔵 | Critical Clarification: User Wants Creators in Browser Window Title Bar Area | ~373 |
|
||||
| #40130 | 11:46 PM | 🔵 | User Clarification: Title Bar Refers to TokenTicker Component, Not Page Header | ~346 |
|
||||
| #40129 | " | ✅ | Build Verification Passed After Reverting Header Changes | ~368 |
|
||||
| #40128 | " | 🟣 | Token Creators Relocated to Title Bar Next to Ticker | ~500 |
|
||||
| #40127 | 11:45 PM | 🔄 | Reverted Token Creator Circles from Header - Removed Title Bar Implementation | ~376 |
|
||||
| #40125 | 11:44 PM | ✅ | Production Build Successfully Compiled After UI Refactoring | ~338 |
|
||||
| #40124 | " | 🔄 | Removed Unused User Icon Import from lucide-react | ~328 |
|
||||
| #40123 | " | 🔄 | Identified Unused User Icon Import After Creator Section Removal | ~311 |
|
||||
| #40122 | 11:43 PM | 🔄 | Removed Duplicate Token Creators Section from Bottom of Page | ~340 |
|
||||
| #40121 | " | 🟣 | Token Creators Relocated to Title Bar as Circular Avatars | ~472 |
|
||||
| #40120 | " | 🔵 | Token Creator Display Structure Located | ~332 |
|
||||
| #40118 | 11:41 PM | 🔴 | Pause-on-Hover Styling Not Applied | ~326 |
|
||||
| #40117 | " | ✅ | Ticker Repositioned to Top of Page Outside Container | ~304 |
|
||||
| #40116 | 11:40 PM | ✅ | Ticker Repositioned to Top of Page | ~326 |
|
||||
| #40112 | 11:39 PM | 🔴 | Marquee Animations Fixed by Moving Outside Tailwind Theme Block | ~373 |
|
||||
| #40107 | 11:35 PM | 🟣 | Token Ticker Integrated into Dashboard | ~362 |
|
||||
| #40106 | " | 🟣 | Token Selection Handler for Ticker Clicks | ~306 |
|
||||
| #40105 | 11:34 PM | ✅ | TokenTicker Import Added to Homepage | ~145 |
|
||||
| #40094 | 11:33 PM | 🟣 | Marquee Animation Keyframes Added to Global CSS | ~319 |
|
||||
| #40092 | " | 🔵 | Tailwind CSS 4 Configuration in Global Styles | ~288 |
|
||||
| #40082 | 11:32 PM | 🔵 | Existing Bagalytics Token Analytics Dashboard | ~394 |
|
||||
| #40053 | 11:13 PM | 🔵 | Complete Codebase Exploration for Caching Implementation | ~550 |
|
||||
| #40051 | 11:12 PM | 🔵 | Main Dashboard Component Architecture | ~503 |
|
||||
| #40044 | 11:08 PM | ✅ | Production build successfully compiled with UI improvements | ~260 |
|
||||
| #40043 | 11:07 PM | 🔴 | Replaced simulated fee history with real hourly data from API | ~377 |
|
||||
| #40042 | " | 🔴 | Removed simulated fee history generator function and state | ~353 |
|
||||
| #40041 | " | 🔄 | Added HourlyFee interface and hourlyFees field to TokenData for real historical data support | ~297 |
|
||||
| #40032 | 10:52 PM | 🟣 | Added manual refresh button and last updated timestamp to Fee Projections card | ~335 |
|
||||
| #40031 | " | 🟣 | Added timestamp tracking for data refresh updates | ~256 |
|
||||
| #40030 | " | ✅ | Replaced DollarSign icon component with money bag emoji in header logo | ~243 |
|
||||
| #40029 | " | 🟣 | Added timestamp tracking state for data updates | ~236 |
|
||||
| #40028 | " | ✅ | Added horizontal padding to token address input field | ~237 |
|
||||
| #40027 | 10:49 PM | 🟣 | Added "24h Stats" label to trading activity metrics section | ~267 |
|
||||
| #40026 | 10:48 PM | ✅ | Replaced misleading price volatility alert with factual trading activity metrics | ~390 |
|
||||
| #40025 | 10:46 PM | 🟣 | Replaced misleading price volatility alert with comprehensive trading activity metrics | ~391 |
|
||||
| #40024 | " | 🔵 | Identified misleading price volatility alert in UI | ~358 |
|
||||
| #40021 | 10:39 PM | 🔄 | Restructured Fees Chart Card to Remove CardContent Wrapper | ~361 |
|
||||
| #40020 | " | 🔄 | Simplified Fees Chart Card by Removing CardHeader and CardTitle Components | ~365 |
|
||||
| #40019 | 10:38 PM | ✅ | Standardized Fee Projections Card Padding | ~288 |
|
||||
| #40018 | " | ✅ | Standardized Token Details Card Padding | ~285 |
|
||||
| #40017 | " | 🔄 | Simplified MetricCard Component by Removing CardHeader and CardContent Wrappers | ~370 |
|
||||
| #40016 | " | 🔴 | Fixed 24h Fees Card Border and Cleaned Up Unnecessary Classes | ~354 |
|
||||
| #40015 | " | ✅ | Finalized Lifetime Fees Card with Explicit Border and Unified Hover State | ~368 |
|
||||
| #40014 | " | 🔴 | Added Missing Border Utility to Token Creators Card | ~321 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './parser.js';
|
||||
export * from './prompts.js';
|
||||
@@ -3,5 +3,10 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
### Jan 25, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #41877 | 12:09 PM | ⚖️ | Deploy Existing Consumer Preview Without Creating New Packages | ~361 |
|
||||
| #41873 | 12:03 PM | 🔵 | Claude-mem mode configuration system types documented | ~504 |
|
||||
</claude-mem-context>
|
||||
@@ -262,21 +262,55 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
|
||||
/**
|
||||
* Spawn a detached daemon process
|
||||
* Returns the child PID or undefined if spawn failed
|
||||
*
|
||||
* On Windows, uses WMIC to spawn a truly independent process that
|
||||
* survives parent exit without console popups. WMIC creates processes
|
||||
* that are not associated with the parent's console.
|
||||
*
|
||||
* On Unix, uses standard detached spawn.
|
||||
*
|
||||
* PID file is written by the worker itself after listen() succeeds,
|
||||
* not by the spawner (race-free, works on all platforms).
|
||||
*/
|
||||
export function spawnDaemon(
|
||||
scriptPath: string,
|
||||
port: number,
|
||||
extraEnv: Record<string, string> = {}
|
||||
): number | undefined {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_MEM_WORKER_PORT: String(port),
|
||||
...extraEnv
|
||||
};
|
||||
|
||||
if (isWindows) {
|
||||
// Use WMIC to spawn a process that's independent of the parent console
|
||||
// This avoids the console popup that occurs with detached: true
|
||||
// Paths must be individually quoted for WMIC when they contain spaces
|
||||
const execPath = process.execPath;
|
||||
const script = scriptPath;
|
||||
// WMIC command format: wmic process call create "\"path1\" \"path2\" args"
|
||||
const command = `wmic process call create "\\"${execPath}\\" \\"${script}\\" --daemon"`;
|
||||
|
||||
try {
|
||||
execSync(command, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
// WMIC returns immediately, we can't get the spawned PID easily
|
||||
// Worker will write its own PID file after listen()
|
||||
return 0;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: standard detached spawn
|
||||
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_MEM_WORKER_PORT: String(port),
|
||||
...extraEnv
|
||||
}
|
||||
env
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
@@ -284,6 +318,7 @@ export function spawnDaemon(
|
||||
}
|
||||
|
||||
child.unref();
|
||||
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
|
||||
import { TableNameRow } from '../../types/database.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { isDirectChild } from '../../shared/path-utils.js';
|
||||
import {
|
||||
ObservationSearchResult,
|
||||
SessionSummarySearchResult,
|
||||
@@ -336,15 +337,6 @@ export class SessionSearch {
|
||||
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder)
|
||||
*/
|
||||
private isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
if (!filePath.startsWith(folderPath + '/')) return false;
|
||||
const remainder = filePath.slice(folderPath.length + 1);
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an observation has any files that are direct children of the folder
|
||||
*/
|
||||
@@ -354,7 +346,7 @@ export class SessionSearch {
|
||||
try {
|
||||
const files = JSON.parse(filesJson);
|
||||
if (Array.isArray(files)) {
|
||||
return files.some(f => this.isDirectChild(f, folderPath));
|
||||
return files.some(f => isDirectChild(f, folderPath));
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
@@ -372,7 +364,7 @@ export class SessionSearch {
|
||||
try {
|
||||
const files = JSON.parse(filesJson);
|
||||
if (Array.isArray(files)) {
|
||||
return files.some(f => this.isDirectChild(f, folderPath));
|
||||
return files.some(f => isDirectChild(f, folderPath));
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
|
||||
@@ -83,10 +83,32 @@ export class ChromaSync {
|
||||
private readonly VECTOR_DB_DIR: string;
|
||||
private readonly BATCH_SIZE = 100;
|
||||
|
||||
// Windows: Chroma disabled due to MCP SDK spawning console popups
|
||||
// See: https://github.com/anthropics/claude-mem/issues/675
|
||||
// Will be re-enabled when we migrate to persistent HTTP server
|
||||
private readonly disabled: boolean;
|
||||
|
||||
constructor(project: string) {
|
||||
this.project = project;
|
||||
this.collectionName = `cm__${project}`;
|
||||
this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db');
|
||||
|
||||
// Disable on Windows to prevent console popups from MCP subprocess spawning
|
||||
// The MCP SDK's StdioClientTransport spawns Python processes that create visible windows
|
||||
this.disabled = process.platform === 'win32';
|
||||
if (this.disabled) {
|
||||
logger.warn('CHROMA_SYNC', 'Vector search disabled on Windows (prevents console popups)', {
|
||||
project: this.project,
|
||||
reason: 'MCP SDK subprocess spawning causes visible console windows'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Chroma is disabled (Windows)
|
||||
*/
|
||||
isDisabled(): boolean {
|
||||
return this.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,6 +409,7 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single observation to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncObservation(
|
||||
observationId: number,
|
||||
@@ -397,6 +420,8 @@ export class ChromaSync {
|
||||
createdAtEpoch: number,
|
||||
discoveryTokens: number = 0
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Convert ParsedObservation to StoredObservation format
|
||||
const stored: StoredObservation = {
|
||||
id: observationId,
|
||||
@@ -431,6 +456,7 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single summary to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncSummary(
|
||||
summaryId: number,
|
||||
@@ -441,6 +467,8 @@ export class ChromaSync {
|
||||
createdAtEpoch: number,
|
||||
discoveryTokens: number = 0
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Convert ParsedSummary to StoredSummary format
|
||||
const stored: StoredSummary = {
|
||||
id: summaryId,
|
||||
@@ -491,6 +519,7 @@ export class ChromaSync {
|
||||
/**
|
||||
* Sync a single user prompt to Chroma
|
||||
* Blocks until sync completes, throws on error
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async syncUserPrompt(
|
||||
promptId: number,
|
||||
@@ -500,6 +529,8 @@ export class ChromaSync {
|
||||
promptNumber: number,
|
||||
createdAtEpoch: number
|
||||
): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Create StoredUserPrompt format
|
||||
const stored: StoredUserPrompt = {
|
||||
id: promptId,
|
||||
@@ -614,8 +645,11 @@ export class ChromaSync {
|
||||
* Backfill: Sync all observations missing from Chroma
|
||||
* Reads from SQLite and syncs in batches
|
||||
* Throws error if backfill fails
|
||||
* No-op on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async ensureBackfilled(): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
logger.info('CHROMA_SYNC', 'Starting smart backfill', { project: this.project });
|
||||
|
||||
await this.ensureCollection();
|
||||
@@ -782,12 +816,17 @@ export class ChromaSync {
|
||||
/**
|
||||
* Query Chroma collection for semantic search
|
||||
* Used by SearchManager for vector-based search
|
||||
* Returns empty results on Windows (Chroma disabled to prevent console popups)
|
||||
*/
|
||||
async queryChroma(
|
||||
query: string,
|
||||
limit: number,
|
||||
whereFilter?: Record<string, any>
|
||||
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
|
||||
if (this.disabled) {
|
||||
return { ids: [], distances: [], metadatas: [] };
|
||||
}
|
||||
|
||||
await this.ensureConnection();
|
||||
|
||||
if (!this.client) {
|
||||
|
||||
@@ -69,6 +69,9 @@ import { SearchRoutes } from './worker/http/routes/SearchRoutes.js';
|
||||
import { SettingsRoutes } from './worker/http/routes/SettingsRoutes.js';
|
||||
import { LogsRoutes } from './worker/http/routes/LogsRoutes.js';
|
||||
|
||||
// Process management for zombie cleanup (Issue #737)
|
||||
import { startOrphanReaper, reapOrphanedProcesses } from './worker/ProcessRegistry.js';
|
||||
|
||||
/**
|
||||
* Build JSON status output for hook framework communication.
|
||||
* This is a pure function extracted for testability.
|
||||
@@ -121,6 +124,9 @@ export class WorkerService {
|
||||
private initializationComplete: Promise<void>;
|
||||
private resolveInitialization!: () => void;
|
||||
|
||||
// Orphan reaper cleanup function (Issue #737)
|
||||
private stopOrphanReaper: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize the promise that will resolve when background initialization completes
|
||||
this.initializationComplete = new Promise((resolve) => {
|
||||
@@ -221,6 +227,16 @@ export class WorkerService {
|
||||
|
||||
// Start HTTP server FIRST - make port available immediately
|
||||
await this.server.listen(port, host);
|
||||
|
||||
// Worker writes its own PID - reliable on all platforms
|
||||
// This happens after listen() succeeds, ensuring the worker is actually ready
|
||||
// On Windows, the spawner's PID is cmd.exe (useless), so worker must write its own
|
||||
writePidFile({
|
||||
pid: process.pid,
|
||||
port,
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
logger.info('SYSTEM', 'Worker started', { host, port, pid: process.pid });
|
||||
|
||||
// Do slow initialization in background (non-blocking)
|
||||
@@ -293,6 +309,16 @@ export class WorkerService {
|
||||
this.resolveInitialization();
|
||||
logger.info('SYSTEM', 'Background initialization complete');
|
||||
|
||||
// Start orphan reaper to clean up zombie processes (Issue #737)
|
||||
this.stopOrphanReaper = startOrphanReaper(() => {
|
||||
const activeIds = new Set<number>();
|
||||
for (const [id] of this.sessionManager['sessions']) {
|
||||
activeIds.add(id);
|
||||
}
|
||||
return activeIds;
|
||||
});
|
||||
logger.info('SYSTEM', 'Started orphan reaper (runs every 5 minutes)');
|
||||
|
||||
// Auto-recover orphaned queues (fire-and-forget with error logging)
|
||||
this.processPendingQueues(50).then(result => {
|
||||
if (result.sessionsStarted > 0) {
|
||||
@@ -394,6 +420,12 @@ export class WorkerService {
|
||||
* Shutdown the worker service
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Stop orphan reaper before shutdown (Issue #737)
|
||||
if (this.stopOrphanReaper) {
|
||||
this.stopOrphanReaper();
|
||||
this.stopOrphanReaper = null;
|
||||
}
|
||||
|
||||
await performGracefulShutdown({
|
||||
server: this.server.getHttpServer(),
|
||||
sessionManager: this.sessionManager,
|
||||
@@ -482,7 +514,8 @@ async function main() {
|
||||
exitWithStatus('error', 'Failed to spawn worker daemon');
|
||||
}
|
||||
|
||||
writePidFile({ pid, port, startedAt: new Date().toISOString() });
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
if (!healthy) {
|
||||
@@ -526,7 +559,8 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
writePidFile({ pid, port, startedAt: new Date().toISOString() });
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
if (!healthy) {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* ProcessRegistry: Track spawned Claude subprocesses
|
||||
*
|
||||
* Fixes Issue #737: Claude haiku subprocesses don't terminate properly,
|
||||
* causing zombie process accumulation (user reported 155 processes / 51GB RAM).
|
||||
*
|
||||
* Root causes:
|
||||
* 1. SDK's SpawnedProcess interface hides subprocess PIDs
|
||||
* 2. deleteSession() doesn't verify subprocess exit before cleanup
|
||||
* 3. abort() is fire-and-forget with no confirmation
|
||||
*
|
||||
* Solution:
|
||||
* - Use SDK's spawnClaudeCodeProcess option to capture PIDs
|
||||
* - Track all spawned processes with session association
|
||||
* - Verify exit on session deletion with timeout + SIGKILL escalation
|
||||
* - Safety net orphan reaper runs every 5 minutes
|
||||
*/
|
||||
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { OBSERVER_CONFIG_DIR, ensureDir } from '../../shared/paths.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface TrackedProcess {
|
||||
pid: number;
|
||||
sessionDbId: number;
|
||||
spawnedAt: number;
|
||||
process: ChildProcess;
|
||||
}
|
||||
|
||||
// PID Registry - tracks spawned Claude subprocesses
|
||||
const processRegistry = new Map<number, TrackedProcess>();
|
||||
|
||||
/**
|
||||
* Register a spawned process in the registry
|
||||
*/
|
||||
export function registerProcess(pid: number, sessionDbId: number, process: ChildProcess): void {
|
||||
processRegistry.set(pid, { pid, sessionDbId, spawnedAt: Date.now(), process });
|
||||
logger.info('PROCESS', `Registered PID ${pid} for session ${sessionDbId}`, { pid, sessionDbId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a process from the registry
|
||||
*/
|
||||
export function unregisterProcess(pid: number): void {
|
||||
processRegistry.delete(pid);
|
||||
logger.debug('PROCESS', `Unregistered PID ${pid}`, { pid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process info by session ID
|
||||
* Warns if multiple processes found (indicates race condition)
|
||||
*/
|
||||
export function getProcessBySession(sessionDbId: number): TrackedProcess | undefined {
|
||||
const matches: TrackedProcess[] = [];
|
||||
for (const [, info] of processRegistry) {
|
||||
if (info.sessionDbId === sessionDbId) matches.push(info);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
logger.warn('PROCESS', `Multiple processes found for session ${sessionDbId}`, {
|
||||
count: matches.length,
|
||||
pids: matches.map(m => m.pid)
|
||||
});
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active PIDs (for debugging)
|
||||
*/
|
||||
export function getActiveProcesses(): Array<{ pid: number; sessionDbId: number; ageMs: number }> {
|
||||
const now = Date.now();
|
||||
return Array.from(processRegistry.values()).map(info => ({
|
||||
pid: info.pid,
|
||||
sessionDbId: info.sessionDbId,
|
||||
ageMs: now - info.spawnedAt
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a process to exit with timeout, escalating to SIGKILL if needed
|
||||
* Uses event-based waiting instead of polling to avoid CPU overhead
|
||||
*/
|
||||
export async function ensureProcessExit(tracked: TrackedProcess, timeoutMs: number = 5000): Promise<void> {
|
||||
const { pid, process: proc } = tracked;
|
||||
|
||||
// Already exited?
|
||||
if (proc.killed || proc.exitCode !== null) {
|
||||
unregisterProcess(pid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for graceful exit with timeout using event-based approach
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
proc.once('exit', () => resolve());
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
|
||||
await Promise.race([exitPromise, timeoutPromise]);
|
||||
|
||||
// Check if exited gracefully
|
||||
if (proc.killed || proc.exitCode !== null) {
|
||||
unregisterProcess(pid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout: escalate to SIGKILL
|
||||
logger.warn('PROCESS', `PID ${pid} did not exit after ${timeoutMs}ms, sending SIGKILL`, { pid, timeoutMs });
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
|
||||
// Brief wait for SIGKILL to take effect
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
unregisterProcess(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill system-level orphans (ppid=1 on Unix)
|
||||
* These are Claude processes whose parent died unexpectedly
|
||||
*/
|
||||
async function killSystemOrphans(): Promise<number> {
|
||||
if (process.platform === 'win32') {
|
||||
return 0; // Windows doesn't have ppid=1 orphan concept
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'ps -eo pid,ppid,args 2>/dev/null | grep -E "claude.*haiku|claude.*output-format" | grep -v grep'
|
||||
);
|
||||
|
||||
let killed = 0;
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)/);
|
||||
if (match && parseInt(match[2]) === 1) { // ppid=1 = orphan
|
||||
const orphanPid = parseInt(match[1]);
|
||||
logger.warn('PROCESS', `Killing system orphan PID ${orphanPid}`, { pid: orphanPid });
|
||||
try {
|
||||
process.kill(orphanPid, 'SIGKILL');
|
||||
killed++;
|
||||
} catch {
|
||||
// Already dead or permission denied
|
||||
}
|
||||
}
|
||||
}
|
||||
return killed;
|
||||
} catch {
|
||||
return 0; // No matches or error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap orphaned processes - both registry-tracked and system-level
|
||||
*/
|
||||
export async function reapOrphanedProcesses(activeSessionIds: Set<number>): Promise<number> {
|
||||
let killed = 0;
|
||||
|
||||
// Registry-based: kill processes for dead sessions
|
||||
for (const [pid, info] of processRegistry) {
|
||||
if (activeSessionIds.has(info.sessionDbId)) continue; // Active = safe
|
||||
|
||||
logger.warn('PROCESS', `Killing orphan PID ${pid} (session ${info.sessionDbId} gone)`, { pid, sessionDbId: info.sessionDbId });
|
||||
try {
|
||||
info.process.kill('SIGKILL');
|
||||
killed++;
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
unregisterProcess(pid);
|
||||
}
|
||||
|
||||
// System-level: find ppid=1 orphans
|
||||
killed += await killSystemOrphans();
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom spawn function for SDK that captures PIDs
|
||||
*
|
||||
* The SDK's spawnClaudeCodeProcess option allows us to intercept subprocess
|
||||
* creation and capture the PID before the SDK hides it.
|
||||
*
|
||||
* IMPORTANT (Issue #832): We set CLAUDE_CONFIG_DIR to isolate observer sessions.
|
||||
* This prevents observer sessions from appearing in `claude --resume` list,
|
||||
* which was causing 34%+ of resume entries to be internal plugin sessions.
|
||||
*/
|
||||
export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
// Ensure observer config directory exists
|
||||
ensureDir(OBSERVER_CONFIG_DIR);
|
||||
|
||||
return (spawnOptions: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
}) => {
|
||||
// Inject CLAUDE_CONFIG_DIR to isolate observer sessions (Issue #832)
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env,
|
||||
CLAUDE_CONFIG_DIR: OBSERVER_CONFIG_DIR
|
||||
};
|
||||
|
||||
const child = spawn(spawnOptions.command, spawnOptions.args, {
|
||||
cwd: spawnOptions.cwd,
|
||||
env: isolatedEnv,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: spawnOptions.signal, // CRITICAL: Pass signal for AbortController integration
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
// Register PID
|
||||
if (child.pid) {
|
||||
registerProcess(child.pid, sessionDbId, child);
|
||||
|
||||
// Auto-unregister on exit
|
||||
child.on('exit', () => {
|
||||
if (child.pid) {
|
||||
unregisterProcess(child.pid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return SDK-compatible interface
|
||||
return {
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
get killed() { return child.killed; },
|
||||
get exitCode() { return child.exitCode; },
|
||||
kill: child.kill.bind(child),
|
||||
on: child.on.bind(child),
|
||||
once: child.once.bind(child),
|
||||
off: child.off.bind(child)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the orphan reaper interval
|
||||
* Returns cleanup function to stop the interval
|
||||
*/
|
||||
export function startOrphanReaper(getActiveSessionIds: () => Set<number>, intervalMs: number = 5 * 60 * 1000): () => void {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const activeIds = getActiveSessionIds();
|
||||
const killed = await reapOrphanedProcesses(activeIds);
|
||||
if (killed > 0) {
|
||||
logger.info('PROCESS', `Reaper cleaned up ${killed} orphaned processes`, { killed });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PROCESS', 'Reaper error', {}, error as Error);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Return cleanup function
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import type { ActiveSession, SDKUserMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { processAgentResponse, type WorkerRef } from './agents/index.js';
|
||||
import { createPidCapturingSpawn, getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
|
||||
|
||||
// Import Agent SDK (assumes it's installed)
|
||||
// @ts-ignore - Agent SDK types may not be available
|
||||
@@ -99,6 +100,7 @@ export class SDKAgent {
|
||||
|
||||
// Run Agent SDK query loop
|
||||
// Only resume if we have a captured memory session ID
|
||||
// Use custom spawn to capture PIDs for zombie process cleanup (Issue #737)
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
@@ -109,7 +111,9 @@ export class SDKAgent {
|
||||
...(hasRealMemorySessionId && session.lastPromptNumber > 1 && { resume: session.memorySessionId }),
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
// Custom spawn function captures PIDs to fix zombie process accumulation
|
||||
spawnClaudeCodeProcess: createPidCapturingSpawn(session.sessionDbId)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { logger } from '../../utils/logger.js';
|
||||
import type { ActiveSession, PendingMessage, PendingMessageWithId, ObservationData } from '../worker-types.js';
|
||||
import { PendingMessageStore } from '../sqlite/PendingMessageStore.js';
|
||||
import { SessionQueueProcessor } from '../queue/SessionQueueProcessor.js';
|
||||
import { getProcessBySession, ensureProcessExit } from './ProcessRegistry.js';
|
||||
|
||||
export class SessionManager {
|
||||
private dbManager: DatabaseManager;
|
||||
@@ -105,6 +106,15 @@ export class SessionManager {
|
||||
memory_session_id: dbSession.memory_session_id
|
||||
});
|
||||
|
||||
// Log warning if we're discarding a stale memory_session_id (Issue #817)
|
||||
if (dbSession.memory_session_id) {
|
||||
logger.warn('SESSION', `Discarding stale memory_session_id from previous worker instance (Issue #817)`, {
|
||||
sessionDbId,
|
||||
staleMemorySessionId: dbSession.memory_session_id,
|
||||
reason: 'SDK context lost on worker restart - will capture new ID'
|
||||
});
|
||||
}
|
||||
|
||||
// Use currentUserPrompt if provided, otherwise fall back to database (first prompt)
|
||||
const userPrompt = currentUserPrompt || dbSession.user_prompt;
|
||||
|
||||
@@ -123,11 +133,15 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
// Create active session
|
||||
// Load memorySessionId from database if previously captured (enables resume across restarts)
|
||||
// CRITICAL: Do NOT load memorySessionId from database here (Issue #817)
|
||||
// When creating a new in-memory session, any database memory_session_id is STALE
|
||||
// because the SDK context was lost when the worker restarted. The SDK agent will
|
||||
// capture a new memorySessionId on the first response and persist it.
|
||||
// Loading stale memory_session_id causes "No conversation found" crashes on resume.
|
||||
session = {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || null,
|
||||
memorySessionId: null, // Always start fresh - SDK will capture new ID
|
||||
project: dbSession.project,
|
||||
userPrompt,
|
||||
pendingMessages: [],
|
||||
@@ -142,10 +156,11 @@ export class SessionManager {
|
||||
currentProvider: null // Will be set when generator starts
|
||||
};
|
||||
|
||||
logger.debug('SESSION', 'Creating new session object', {
|
||||
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || '(none - fresh session)',
|
||||
dbMemorySessionId: dbSession.memory_session_id || '(none in DB)',
|
||||
memorySessionId: '(cleared - will capture fresh from SDK)',
|
||||
lastPromptNumber: promptNumber || this.dbManager.getSessionStore().getPromptNumberFromUserPrompts(dbSession.content_session_id)
|
||||
});
|
||||
|
||||
@@ -256,6 +271,7 @@ export class SessionManager {
|
||||
|
||||
/**
|
||||
* Delete a session (abort SDK agent and cleanup)
|
||||
* Verifies subprocess exit to prevent zombie process accumulation (Issue #737)
|
||||
*/
|
||||
async deleteSession(sessionDbId: number): Promise<void> {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
@@ -265,17 +281,27 @@ export class SessionManager {
|
||||
|
||||
const sessionDuration = Date.now() - session.startTime;
|
||||
|
||||
// Abort the SDK agent
|
||||
// 1. Abort the SDK agent
|
||||
session.abortController.abort();
|
||||
|
||||
// Wait for generator to finish
|
||||
// 2. Wait for generator to finish
|
||||
if (session.generatorPromise) {
|
||||
await session.generatorPromise.catch(error => {
|
||||
await session.generatorPromise.catch(() => {
|
||||
logger.debug('SYSTEM', 'Generator already failed, cleaning up', { sessionId: session.sessionDbId });
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
// 3. Verify subprocess exit with 5s timeout (Issue #737 fix)
|
||||
const tracked = getProcessBySession(sessionDbId);
|
||||
if (tracked && !tracked.process.killed && tracked.process.exitCode === null) {
|
||||
logger.debug('SESSION', `Waiting for subprocess PID ${tracked.pid} to exit`, {
|
||||
sessionId: sessionDbId,
|
||||
pid: tracked.pid
|
||||
});
|
||||
await ensureProcessExit(tracked, 5000);
|
||||
}
|
||||
|
||||
// 4. Cleanup
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Shared path utilities for CLAUDE.md file generation
|
||||
*
|
||||
* These utilities handle path normalization and matching, particularly
|
||||
* for comparing absolute and relative paths in folder CLAUDE.md generation.
|
||||
*
|
||||
* @see Issue #794 - Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize path separators to forward slashes, collapse consecutive slashes,
|
||||
* and remove trailing slashes.
|
||||
*
|
||||
* @example
|
||||
* normalizePath('app\\api\\router.py') // 'app/api/router.py'
|
||||
* normalizePath('app//api///router.py') // 'app/api/router.py'
|
||||
* normalizePath('app/api/') // 'app/api'
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder).
|
||||
*
|
||||
* Handles path format mismatches where folderPath may be absolute but
|
||||
* filePath is stored as relative in the database.
|
||||
*
|
||||
* NOTE: This uses suffix matching which assumes both paths are relative to
|
||||
* the same project root. It may produce false positives if used across
|
||||
* different project roots, but this is mitigated by project-scoped queries.
|
||||
*
|
||||
* @param filePath - Path to the file (e.g., "app/api/router.py" or "/Users/x/project/app/api/router.py")
|
||||
* @param folderPath - Path to the folder (e.g., "app/api" or "/Users/x/project/app/api")
|
||||
* @returns true if file is directly in folder, false if in a subfolder or different folder
|
||||
*
|
||||
* @example
|
||||
* // Same format (both relative)
|
||||
* isDirectChild('app/api/router.py', 'app/api') // true
|
||||
* isDirectChild('app/api/v1/router.py', 'app/api') // false (in subfolder)
|
||||
*
|
||||
* @example
|
||||
* // Mixed format (absolute folder, relative file) - fixes #794
|
||||
* isDirectChild('app/api/router.py', '/Users/dev/project/app/api') // true
|
||||
*/
|
||||
export function isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
const normFile = normalizePath(filePath);
|
||||
const normFolder = normalizePath(folderPath);
|
||||
|
||||
// Strategy 1: Direct prefix match (both paths in same format)
|
||||
if (normFile.startsWith(normFolder + '/')) {
|
||||
const remainder = normFile.slice(normFolder.length + 1);
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
// Strategy 2: Handle absolute folderPath with relative filePath
|
||||
// e.g., folderPath="/Users/x/project/app/api" and filePath="app/api/router.py"
|
||||
const folderSegments = normFolder.split('/');
|
||||
const fileSegments = normFile.split('/');
|
||||
|
||||
if (fileSegments.length < 2) return false; // Need at least folder/file
|
||||
|
||||
const fileDir = fileSegments.slice(0, -1).join('/'); // Directory part of file
|
||||
const fileName = fileSegments[fileSegments.length - 1]; // Actual filename
|
||||
|
||||
// Check if folder path ends with the file's directory path
|
||||
if (normFolder.endsWith('/' + fileDir) || normFolder === fileDir) {
|
||||
// File is a direct child (no additional subdirectories)
|
||||
return !fileName.includes('/');
|
||||
}
|
||||
|
||||
// Check if file's directory is contained at the end of folder path
|
||||
// by progressively checking suffixes
|
||||
for (let i = 0; i < folderSegments.length; i++) {
|
||||
const folderSuffix = folderSegments.slice(i).join('/');
|
||||
if (folderSuffix === fileDir) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const VECTOR_DB_DIR = join(DATA_DIR, 'vector-db');
|
||||
|
||||
// Isolated config directory for observer sessions (Issue #832)
|
||||
// This prevents observer sessions from appearing in `claude --resume` list
|
||||
export const OBSERVER_CONFIG_DIR = join(DATA_DIR, 'observer-config');
|
||||
|
||||
// Claude integration paths
|
||||
export const CLAUDE_SETTINGS_PATH = join(CLAUDE_CONFIG_DIR, 'settings.json');
|
||||
export const CLAUDE_COMMANDS_DIR = join(CLAUDE_CONFIG_DIR, 'commands');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* <claude-mem-context> tags.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { logger } from './logger.js';
|
||||
@@ -76,8 +76,8 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
return existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
}
|
||||
|
||||
// If no tags exist, append tagged content at end
|
||||
@@ -86,17 +86,22 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file to folder with atomic writes.
|
||||
* Creates directory structure if needed.
|
||||
* Only writes to existing folders; skips non-existent paths to prevent
|
||||
* creating spurious directory structures from malformed paths.
|
||||
*
|
||||
* @param folderPath - Absolute path to the folder
|
||||
* @param folderPath - Absolute path to the folder (must already exist)
|
||||
* @param newContent - Content to write inside tags
|
||||
*/
|
||||
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const tempFile = `${claudeMdPath}.tmp`;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
// Only write to folders that already exist - never create new directories
|
||||
// This prevents creating spurious folder structures from malformed paths
|
||||
if (!existsSync(folderPath)) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping non-existent folder', { folderPath });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read existing content if file exists
|
||||
let existingContent = '';
|
||||
@@ -320,6 +325,18 @@ export async function updateFolderClaudeMdFiles(
|
||||
}
|
||||
|
||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||
|
||||
// Fix for #794: Don't create new CLAUDE.md files if there's no activity
|
||||
// But update existing ones to show "No recent activity" if they already exist
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const hasNoActivity = formatted.includes('*No recent activity*');
|
||||
const fileExists = existsSync(claudeMdPath);
|
||||
|
||||
if (hasNoActivity && !fileExists) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping empty CLAUDE.md creation', { folderPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
writeClaudeMdToFolder(folderPath, formatted);
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isDirectChild, normalizePath } from '../../../src/shared/path-utils.js';
|
||||
|
||||
/**
|
||||
* Tests for path matching logic, specifically the isDirectChild() algorithm
|
||||
* Covers fix for issue #794: Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*
|
||||
* These tests validate the shared path-utils module which is used by:
|
||||
* - SessionSearch.ts (runtime folder CLAUDE.md generation)
|
||||
* - regenerate-claude-md.ts (CLI regeneration tool)
|
||||
*/
|
||||
|
||||
describe('isDirectChild path matching', () => {
|
||||
describe('same path format', () => {
|
||||
test('returns true for direct child with relative paths', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for direct child with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with relative paths', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', 'app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for unrelated paths', () => {
|
||||
expect(isDirectChild('lib/utils/helper.py', 'app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed path formats (absolute folder, relative file) - fixes #794', () => {
|
||||
test('returns true when absolute folder ends with relative file directory', () => {
|
||||
// This is the exact bug case from #794
|
||||
expect(isDirectChild('app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for deeply nested folder match', () => {
|
||||
expect(isDirectChild('src/components/Button.tsx', '/home/user/project/src/components')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory of matched folder', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when file path does not match folder suffix', () => {
|
||||
expect(isDirectChild('lib/api/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path normalization', () => {
|
||||
test('handles Windows backslash paths', () => {
|
||||
expect(isDirectChild('app\\api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles mixed slashes', () => {
|
||||
expect(isDirectChild('app/api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles trailing slashes on folder path', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api/')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles double slashes (path normalization bug)', () => {
|
||||
expect(isDirectChild('app//api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('collapses multiple consecutive slashes', () => {
|
||||
expect(isDirectChild('app///api///router.py', 'app//api//')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('returns false for single segment file path', () => {
|
||||
expect(isDirectChild('router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty paths', () => {
|
||||
expect(isDirectChild('', 'app/api')).toBe(false);
|
||||
expect(isDirectChild('app/api/router.py', '')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles root-level folders', () => {
|
||||
expect(isDirectChild('src/file.ts', '/project/src')).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents false positive from partial segment match', () => {
|
||||
// "api" folder should not match "api-v2" folder
|
||||
expect(isDirectChild('app/api-v2/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles similar folder names correctly', () => {
|
||||
// "components" should not match "components-old"
|
||||
expect(isDirectChild('src/components-old/Button.tsx', '/project/src/components')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('converts backslashes to forward slashes', () => {
|
||||
expect(normalizePath('app\\api\\router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('collapses consecutive slashes', () => {
|
||||
expect(normalizePath('app//api///router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('removes trailing slashes', () => {
|
||||
expect(normalizePath('app/api/')).toBe('app/api');
|
||||
expect(normalizePath('app/api///')).toBe('app/api');
|
||||
});
|
||||
|
||||
test('handles Windows UNC paths', () => {
|
||||
expect(normalizePath('\\\\server\\share\\file.txt')).toBe('/server/share/file.txt');
|
||||
});
|
||||
|
||||
test('preserves leading slash for absolute paths', () => {
|
||||
expect(normalizePath('/Users/dev/project')).toBe('/Users/dev/project');
|
||||
});
|
||||
});
|
||||
@@ -147,8 +147,22 @@ describe('formatTimelineForClaudeMd', () => {
|
||||
});
|
||||
|
||||
describe('writeClaudeMdToFolder', () => {
|
||||
it('should create CLAUDE.md in new folder', () => {
|
||||
const folderPath = join(tempDir, 'new-folder');
|
||||
it('should skip non-existent folders (fix for spurious directory creation)', () => {
|
||||
const folderPath = join(tempDir, 'non-existent-folder');
|
||||
const content = '# Recent Activity\n\nTest content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Folder and CLAUDE.md should NOT be created
|
||||
expect(existsSync(folderPath)).toBe(false);
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should create CLAUDE.md in existing folder', () => {
|
||||
const folderPath = join(tempDir, 'existing-folder');
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
const content = '# Recent Activity\n\nTest content';
|
||||
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
@@ -180,20 +194,22 @@ describe('writeClaudeMdToFolder', () => {
|
||||
expect(fileContent).not.toContain('Old content');
|
||||
});
|
||||
|
||||
it('should create nested directories', () => {
|
||||
it('should not create nested directories (fix for spurious directory creation)', () => {
|
||||
const folderPath = join(tempDir, 'deep', 'nested', 'folder');
|
||||
const content = 'Nested content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Nested directories should NOT be created
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(true);
|
||||
expect(existsSync(join(tempDir, 'deep'))).toBe(true);
|
||||
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
expect(existsSync(join(tempDir, 'deep'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not leave .tmp file after write (atomic write)', () => {
|
||||
const folderPath = join(tempDir, 'atomic-test');
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
const content = 'Atomic write test';
|
||||
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
@@ -218,6 +234,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
it('should fetch timeline and write CLAUDE.md', async () => {
|
||||
const folderPath = join(tempDir, 'api-test');
|
||||
mkdirSync(folderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
const filePath = join(folderPath, 'test.ts');
|
||||
|
||||
const apiResponse = {
|
||||
@@ -412,6 +429,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
it('should write CLAUDE.md to resolved projectRoot path', async () => {
|
||||
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
|
||||
mkdirSync(subfolderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
|
||||
const apiResponse = {
|
||||
content: [{
|
||||
|
||||
Reference in New Issue
Block a user