Compare commits

...

7 Commits

Author SHA1 Message Date
Alex Newman ddb57ea598 chore: bump version to 10.6.3
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:49:24 -07:00
Alex Newman 6885bdb019 Merge pull request #1518 from thedotmack/thedotmack/patch-plan-issues
fix: patch 7 critical bugs for v10.6.3
2026-03-28 19:48:43 -07:00
Alex Newman 0321f4266d fix: remove import.meta.url banner from CJS files run by Node.js
The MCP server (#!/usr/bin/env node) and context generator run under
Node.js, where import.meta.url throws SyntaxError in CJS mode. Only
the worker-service needs the banner since it runs under Bun.

CJS files under Node.js already have __dirname/__filename natively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:32:43 -07:00
Alex Newman 80d1deedbe fix: address PR review feedback from CodeRabbit
- Add sessionId to summarize.ts warning log for easier triage
- Add APPROVED OVERRIDE annotation to Windows spawn catch block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:34:42 -07:00
Alex Newman 07ab7000a8 fix: patch 7 critical bugs affecting all non-dev-machine users and Windows
1. Fix esbuild inlining build-machine __dirname as string literal — use
   CJS-compatible runtime banner with require("node:url").fileURLToPath
   across worker-service, mcp-server, and context-generator builds.

2. Fix isMainModule check missing .cjs extension and Windows backslash
   path normalization.

3. Wrap extractLastMessage in try-catch to prevent infinite Stop hook
   feedback loop on malformed transcripts (exit 0 instead of exit 2).

4. Replace heavy SessionEnd hook (Node→Bun→1.7MB CJS→HTTP) with
   lightweight inline node -e one-liner (~200ms vs >1s).

5. Add 7 Gemini/OpenRouter error patterns to unrecoverablePatterns
   circuit breaker to prevent 77K+ retry loops on expired API keys.

6. Preserve CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_GIT_BASH_PATH in
   sanitizeEnv instead of stripping them with the CLAUDE_CODE_ prefix.

7. Use PowerShell -EncodedCommand for spawnDaemon to fix path quoting
   when Windows usernames contain spaces.

Closes #1515, #1495, #1475, #1465, #1500, #1513, #1512, #1450, #1460,
#1486, #1449, #1481, #1451, #1480, #1453, #1445

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:20:29 -07:00
Alex Newman a656af2bff feat: improve Gemini CLI timeline display by stripping ANSI colors and providing markdown fallback 2026-03-25 23:51:56 -07:00
Alex Newman e2a230286d docs: update CHANGELOG.md for v10.6.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:14:43 -07:00
18 changed files with 334 additions and 123 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "10.6.2",
"version": "10.6.3",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+16 -23
View File
@@ -2,6 +2,22 @@
All notable changes to claude-mem.
## [v10.6.2] - 2026-03-21
## fix: Activity spinner stuck spinning forever
The viewer UI activity spinner would spin indefinitely because `isAnySessionProcessing()` queried all pending/processing messages in the database globally — including orphaned messages from dead sessions that no generator would ever process. These orphans caused `isProcessing=true` forever.
### Changes
- Scoped `isAnySessionProcessing()` and `hasPendingMessages()` to only check sessions in the active in-memory Map, so orphaned DB messages no longer affect the spinner
- Added `terminateSession()` method enforcing a restart-or-terminate invariant — every generator exit must either restart or fully clean up
- Fixed 3 zombie paths in the `.finally()` handler that previously left sessions alive in memory with no generator running
- Fixed idle-timeout race condition where fresh messages arriving between idle abort and cleanup could be silently dropped
- Removed redundant bare `isProcessing: true` broadcast and eliminated double-iteration in `broadcastProcessingStatus()`
- Replaced inline `require()` with proper accessor via `sessionManager.getPendingMessageStore()`
- Added 8 regression tests for session termination invariant
## [v10.6.1] - 2026-03-18
### New Features
@@ -1095,26 +1111,3 @@ This release adds the `/do` and `/make-plan` development commands to the plugin
https://github.com/thedotmack/claude-mem/compare/v9.0.3...v9.0.4
## [v9.0.3] - 2026-01-10
## Bug Fixes
### Hook Framework JSON Status Output (#655)
Fixed an issue where the worker service startup wasn't producing proper JSON status output for the Claude Code hook framework. This caused hooks to appear stuck or unresponsive during worker initialization.
**Changes:**
- Added `buildStatusOutput()` function for generating structured JSON status output
- Worker now outputs JSON with `status`, `message`, and `continue` fields on stdout
- Proper exit code 0 ensures Windows Terminal compatibility (no tab accumulation)
- `continue: true` flag ensures Claude Code continues processing after hook execution
**Technical Details:**
- Extracted status output generation into a pure, testable function
- Added comprehensive test coverage in `tests/infrastructure/worker-json-status.test.ts`
- 23 passing tests covering unit, CLI integration, and hook framework compatibility
## Housekeeping
- Removed obsolete error handling baseline file
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.6.2",
"version": "10.6.3",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.6.2",
"version": "10.6.3",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+2 -2
View File
@@ -74,8 +74,8 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"timeout": 30
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
"timeout": 5
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "10.6.2",
"version": "10.6.3",
"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
+7 -2
View File
@@ -116,7 +116,11 @@ async function buildHooks() {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
},
banner: {
js: '#!/usr/bin/env bun'
js: [
'#!/usr/bin/env bun',
'var __filename = require("node:url").fileURLToPath(import.meta.url);',
'var __dirname = require("node:path").dirname(__filename);'
].join('\n')
}
});
@@ -176,7 +180,8 @@ async function buildHooks() {
external: ['bun:sqlite'],
define: {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
}
},
// No banner needed: CJS files under Node.js have __dirname/__filename natively
});
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
+128
View File
@@ -0,0 +1,128 @@
import type { PlatformAdapter } from '../types.js';
/**
* Gemini CLI Platform Adapter
*
* Normalizes Gemini CLI's hook JSON to NormalizedHookInput.
* Gemini CLI supports 11 lifecycle hooks; we register 8:
*
* Lifecycle:
* SessionStart → context (inject memory context)
* SessionEnd → session-complete
* PreCompress → summarize
* Notification → observation (system events like ToolPermission)
*
* Agent:
* BeforeAgent → user-message (captures user prompt)
* AfterAgent → observation (full agent response)
*
* Tool:
* BeforeTool → observation (tool intent before execution)
* AfterTool → observation (tool result after execution)
*
* Unmapped (not useful for memory):
* BeforeModel, AfterModel, BeforeToolSelection — model-level events
* that fire per-LLM-call, too chatty for observation capture.
*
* Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp
*
* Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
* Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) ignore flow-control fields.
*/
export const geminiCliAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
// CWD resolution chain: JSON field → env vars → process.cwd()
const cwd = r.cwd
?? process.env.GEMINI_CWD
?? process.env.GEMINI_PROJECT_DIR
?? process.env.CLAUDE_PROJECT_DIR
?? process.cwd();
const sessionId = r.session_id
?? process.env.GEMINI_SESSION_ID
?? undefined;
const hookEventName: string | undefined = r.hook_event_name;
// Tool fields — present in BeforeTool, AfterTool
let toolName: string | undefined = r.tool_name;
let toolInput: unknown = r.tool_input;
let toolResponse: unknown = r.tool_response;
// AfterAgent: synthesize observation shape from the full agent response
if (hookEventName === 'AfterAgent' && r.prompt_response) {
toolName = toolName ?? 'GeminiAgent';
toolInput = toolInput ?? { prompt: r.prompt };
toolResponse = toolResponse ?? { response: r.prompt_response };
}
// BeforeTool: has tool_name and tool_input but no tool_response yet
// Synthesize a marker so observation handler knows this is pre-execution
if (hookEventName === 'BeforeTool' && toolName && !toolResponse) {
toolResponse = { _preExecution: true };
}
// Notification: capture as an observation with notification details
if (hookEventName === 'Notification') {
toolName = toolName ?? 'GeminiNotification';
toolInput = toolInput ?? {
notification_type: r.notification_type,
message: r.message,
};
toolResponse = toolResponse ?? { details: r.details };
}
// Collect platform-specific metadata
const metadata: Record<string, unknown> = {};
if (r.source) metadata.source = r.source; // SessionStart: startup|resume|clear
if (r.reason) metadata.reason = r.reason; // SessionEnd: exit|clear|logout|...
if (r.trigger) metadata.trigger = r.trigger; // PreCompress: auto|manual
if (r.mcp_context) metadata.mcp_context = r.mcp_context; // Tool hooks: MCP server context
if (r.notification_type) metadata.notification_type = r.notification_type;
if (r.stop_hook_active !== undefined) metadata.stop_hook_active = r.stop_hook_active;
if (r.original_request_name) metadata.original_request_name = r.original_request_name;
if (hookEventName) metadata.hook_event_name = hookEventName;
return {
sessionId,
cwd,
prompt: r.prompt,
toolName,
toolInput,
toolResponse,
transcriptPath: r.transcript_path,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
};
},
formatOutput(result) {
// Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
const output: Record<string, unknown> = {};
// Flow control — always include `continue` to prevent accidental agent termination
output.continue = result.continue ?? true;
if (result.suppressOutput !== undefined) {
output.suppressOutput = result.suppressOutput;
}
if (result.systemMessage) {
// Strip ANSI escape sequences: matches colors, text formatting, and terminal control codes
// Gemini CLI often has issues with ANSI escape sequences in tool output (showing them as raw text)
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
output.systemMessage = result.systemMessage.replace(ansiRegex, '');
}
// hookSpecificOutput is a first-class Gemini CLI field — pass through directly
// This includes additionalContext for context injection in SessionStart, BeforeAgent, AfterTool
if (result.hookSpecificOutput) {
output.hookSpecificOutput = {
additionalContext: result.hookSpecificOutput.additionalContext,
};
}
return output;
}
};
+4 -1
View File
@@ -1,16 +1,19 @@
import type { PlatformAdapter } from '../types.js';
import { claudeCodeAdapter } from './claude-code.js';
import { cursorAdapter } from './cursor.js';
import { geminiCliAdapter } from './gemini-cli.js';
import { rawAdapter } from './raw.js';
export function getPlatformAdapter(platform: string): PlatformAdapter {
switch (platform) {
case 'claude-code': return claudeCodeAdapter;
case 'cursor': return cursorAdapter;
case 'gemini':
case 'gemini-cli': return geminiCliAdapter;
case 'raw': return rawAdapter;
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
default: return rawAdapter;
}
}
export { claudeCodeAdapter, cursorAdapter, rawAdapter };
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
+8 -2
View File
@@ -66,9 +66,15 @@ export const contextHandler: EventHandler = {
const additionalContext = contextResult.trim();
const coloredTimeline = colorResult.trim();
const platform = input.platform;
const systemMessage = showTerminalOutput && coloredTimeline
? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}`
// Use colored timeline for display if available, otherwise fall back to
// plain markdown context (especially useful for platforms like Gemini
// where we want to ensure visibility even if colors aren't fetched).
const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : '');
const systemMessage = showTerminalOutput && displayContent
? `${displayContent}\n\nView Observations Live @ http://localhost:${port}`
: undefined;
return {
+7 -1
View File
@@ -35,7 +35,13 @@ export const summarizeHandler: EventHandler = {
// Extract last assistant message from transcript (the work Claude did)
// Note: "user" messages in transcripts are mostly tool_results, not actual user input.
// The user's original request is already stored in user_prompts table.
const lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
let lastAssistantMessage = '';
try {
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
} catch (err) {
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
logger.dataIn('HOOK', 'Stop: Requesting summary', {
hasLastAssistantMessage: !!lastAssistantMessage
@@ -646,18 +646,19 @@ export function spawnDaemon(
return undefined;
}
const escapedRuntimePath = runtimePath.replace(/'/g, "''");
const escapedScriptPath = scriptPath.replace(/'/g, "''");
const psCommand = `Start-Process -FilePath '${escapedRuntimePath}' -ArgumentList '${escapedScriptPath}','--daemon' -WindowStyle Hidden`;
// Use -EncodedCommand to avoid all shell quoting issues with spaces in paths
const psScript = `Start-Process -FilePath '${runtimePath.replace(/'/g, "''")}' -ArgumentList @('${scriptPath.replace(/'/g, "''")}','--daemon') -WindowStyle Hidden`;
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
try {
execSync(`powershell -NoProfile -Command "${psCommand}"`, {
execSync(`powershell -NoProfile -EncodedCommand ${encodedCommand}`, {
stdio: 'ignore',
windowsHide: true,
env
});
return 0;
} catch (error) {
// APPROVED OVERRIDE: Windows daemon spawn is best-effort; log and let callers fall back to health checks/retry flow.
logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error as Error);
return undefined;
}
+11 -1
View File
@@ -578,6 +578,13 @@ export class WorkerService {
'ENOENT',
'spawn',
'Invalid API key',
'API_KEY_INVALID',
'API key expired',
'API key not valid',
'PERMISSION_DENIED',
'Gemini API error: 400',
'Gemini API error: 401',
'Gemini API error: 403',
'FOREIGN KEY constraint failed',
];
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
@@ -1262,7 +1269,10 @@ async function main() {
// Check if running as main module in both ESM and CommonJS
const isMainModule = typeof require !== 'undefined' && typeof module !== 'undefined'
? require.main === module || !module.parent
: import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('worker-service');
: import.meta.url === `file://${process.argv[1]}`
|| process.argv[1]?.endsWith('worker-service')
|| process.argv[1]?.endsWith('worker-service.cjs')
|| process.argv[1]?.replaceAll('\\', '/') === __filename?.replaceAll('\\', '/');
if (isMainModule) {
main().catch((error) => {
+7
View File
@@ -6,11 +6,18 @@ export const ENV_EXACT_MATCHES = new Set([
'MCP_SESSION_ID',
]);
/** Vars that start with CLAUDE_CODE_ but must be preserved for subprocess auth/tooling */
export const ENV_PRESERVE = new Set([
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_GIT_BASH_PATH',
]);
export function sanitizeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (ENV_PRESERVE.has(key)) { sanitized[key] = value; continue; }
if (ENV_EXACT_MATCHES.has(key)) continue;
if (ENV_PREFIXES.some(prefix => key.startsWith(prefix))) continue;
sanitized[key] = value;
@@ -67,11 +67,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
expect(parsed.hooks).toBeDefined();
});
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands (except inline hooks)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
// SessionEnd uses a lightweight inline node -e command (no plugin root needed)
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
@@ -82,12 +85,14 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
}
});
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands except inline hooks (#1215)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const inlineHookEvents = new Set(['SessionEnd']);
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
if (inlineHookEvents.has(eventName)) continue;
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
@@ -97,6 +102,18 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
}
}
});
it('should use lightweight inline node command for SessionEnd hook', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const sessionEndHooks = parsed.hooks.SessionEnd;
expect(sessionEndHooks).toBeDefined();
expect(sessionEndHooks.length).toBe(1);
const command = sessionEndHooks[0].hooks[0].command;
expect(command).toContain('node -e');
expect(command).toContain('/api/sessions/complete');
expect(sessionEndHooks[0].hooks[0].timeout).toBeLessThanOrEqual(10);
});
});
describe('Plugin Distribution - package.json Files Field', () => {
+36 -3
View File
@@ -14,7 +14,7 @@ describe('sanitizeEnv', () => {
expect(result.PATH).toBe('/usr/bin');
});
it('strips variables with CLAUDE_CODE_ prefix', () => {
it('strips variables with CLAUDE_CODE_ prefix but preserves allowed ones', () => {
const result = sanitizeEnv({
CLAUDE_CODE_BAR: 'baz',
CLAUDE_CODE_OAUTH_TOKEN: 'token',
@@ -22,7 +22,7 @@ describe('sanitizeEnv', () => {
});
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
expect(result.HOME).toBe('/home/user');
});
@@ -115,9 +115,42 @@ describe('sanitizeEnv', () => {
expect(result.CLAUDECODE).toBeUndefined();
expect(result.CLAUDECODE_FOO).toBeUndefined();
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
expect(result.MCP_SESSION_ID).toBeUndefined();
});
it('preserves CLAUDE_CODE_GIT_BASH_PATH through sanitization', () => {
const result = sanitizeEnv({
CLAUDE_CODE_GIT_BASH_PATH: 'C:\\Program Files\\Git\\bin\\bash.exe',
PATH: '/usr/bin',
HOME: '/home/user'
});
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
expect(result.PATH).toBe('/usr/bin');
expect(result.HOME).toBe('/home/user');
});
it('selectively preserves only allowed CLAUDE_CODE_* vars while stripping others', () => {
const result = sanitizeEnv({
CLAUDE_CODE_OAUTH_TOKEN: 'my-oauth-token',
CLAUDE_CODE_GIT_BASH_PATH: '/usr/bin/bash',
CLAUDE_CODE_RANDOM_OTHER: 'should-be-stripped',
CLAUDE_CODE_INTERNAL_FLAG: 'should-be-stripped',
PATH: '/usr/bin'
});
// Preserved: explicitly allowed CLAUDE_CODE_* vars
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('my-oauth-token');
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('/usr/bin/bash');
// Stripped: all other CLAUDE_CODE_* vars
expect(result.CLAUDE_CODE_RANDOM_OTHER).toBeUndefined();
expect(result.CLAUDE_CODE_INTERNAL_FLAG).toBeUndefined();
// Preserved: normal env vars
expect(result.PATH).toBe('/usr/bin');
});
});