Merge main into thedotmack/file-read-timeline-inject

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-01 16:11:41 -07:00
21 changed files with 596 additions and 220 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "10.6.1",
"version": "10.6.3",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+49 -44
View File
@@ -2,6 +2,55 @@
All notable changes to claude-mem.
## [v10.6.3] - 2026-03-29
## v10.6.3 — Critical Patch Release
### Bug Fixes
- **Fix MCP server crash**: Removed erroneous `import.meta.url` ESM-compat banner from CJS files that caused Node.js startup failures
- **Fix 7 critical bugs** affecting all non-dev-machine users and Windows:
- Hook registration paths corrected for plugin distribution
- Worker service spawn handling hardened for Windows
- Environment sanitization for cross-platform compatibility
- ProcessManager Windows spawn catch block improvements
- SessionEnd inline hook exemption in regression tests
- `summarize.ts` warning log now includes `sessionId` for triage
- **CodeRabbit review feedback** addressed from PR #1518
### Improvements
- **Gemini CLI integration**: Strip ANSI color codes from timeline display, provide markdown fallback
### Files Changed
- `plugin/hooks/hooks.json`
- `plugin/scripts/mcp-server.cjs`
- `plugin/scripts/worker-service.cjs`
- `scripts/build-hooks.js`
- `src/cli/handlers/summarize.ts`
- `src/services/infrastructure/ProcessManager.ts`
- `src/services/worker-service.ts`
- `src/supervisor/env-sanitizer.ts`
- `tests/infrastructure/plugin-distribution.test.ts`
- `tests/supervisor/env-sanitizer.test.ts`
## [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
@@ -1074,47 +1123,3 @@ This release contains a significant refactoring of `worker-service.ts`, removing
- 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
This release adds the `/do` and `/make-plan` development commands to the plugin distribution, making them available to all users who install the plugin from the marketplace.
### Features
- **Development Commands Now Distributed with Plugin** (#666)
- `/do` command - Execute tasks with structured workflow
- `/make-plan` command - Create detailed implementation plans
- Commands now available at `plugin/commands/` for all users
### Documentation
- Revised Arabic README for clarity and corrections (#661)
### Full Changelog
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.1",
"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.1",
"version": "10.6.3",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+2 -2
View File
@@ -86,8 +86,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.1",
"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;
}
+55 -24
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))) {
@@ -653,30 +660,26 @@ export class WorkerService {
// Do NOT restart after unrecoverable errors - prevents infinite loops
if (hadUnrecoverableError) {
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
sessionId: session.sessionDbId
});
this.broadcastProcessingStatus();
this.terminateSession(session.sessionDbId, 'unrecoverable_error');
return;
}
// Store for pending-count check below
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
// Idle timeout means no new work arrived for 3 minutes - don't restart
// No need to reset stale processing messages here — claimNextMessage() self-heals
if (session.idleTimedOut) {
logger.info('SYSTEM', 'Generator exited due to idle timeout, not restarting', {
sessionId: session.sessionDbId
});
session.idleTimedOut = false; // Reset flag
this.broadcastProcessingStatus();
return;
}
const pendingStore = this.sessionManager.getPendingMessageStore();
// Check if there's pending work that needs processing with a fresh AbortController
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
// Idle timeout means no new work arrived for 3 minutes - don't restart
// But check pendingCount first: a message may have arrived between idle
// abort and .finally(), and we must not abandon it
if (session.idleTimedOut) {
session.idleTimedOut = false; // Reset flag
if (pendingCount === 0) {
this.terminateSession(session.sessionDbId, 'idle_timeout');
return;
}
// Fall through to pending-work restart below
}
const MAX_PENDING_RESTARTS = 3;
if (pendingCount > 0) {
@@ -690,7 +693,7 @@ export class WorkerService {
consecutiveRestarts: session.consecutiveRestarts
});
session.consecutiveRestarts = 0;
this.broadcastProcessingStatus();
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
return;
}
@@ -703,12 +706,13 @@ export class WorkerService {
session.abortController = new AbortController();
// Restart processor
this.startSessionProcessor(session, 'pending-work-restart');
this.broadcastProcessingStatus();
} else {
// Successful completion with no pending work — reset counter
// Successful completion with no pending work — clean up session
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
session.consecutiveRestarts = 0;
this.sessionManager.removeSessionImmediate(session.sessionDbId);
}
this.broadcastProcessingStatus();
});
}
@@ -784,6 +788,30 @@ export class WorkerService {
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
}
/**
* Terminate a session that will not restart.
* Enforces the restart-or-terminate invariant: every generator exit
* must either call startSessionProcessor() or terminateSession().
* No zombie sessions allowed.
*
* GENERATOR EXIT INVARIANT:
* .finally() restart? startSessionProcessor()
* no? terminateSession()
*/
private terminateSession(sessionDbId: number, reason: string): void {
const pendingStore = this.sessionManager.getPendingMessageStore();
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
logger.info('SYSTEM', 'Session terminated', {
sessionId: sessionDbId,
reason,
abandonedMessages: abandoned
});
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
this.sessionManager.removeSessionImmediate(sessionDbId);
}
/**
* Process pending session queues
*/
@@ -907,8 +935,8 @@ export class WorkerService {
* Broadcast processing status change to SSE clients
*/
broadcastProcessingStatus(): void {
const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalActiveWork();
const isProcessing = queueDepth > 0;
const activeSessions = this.sessionManager.getActiveSessionCount();
logger.info('WORKER', 'Broadcasting processing status', {
@@ -1241,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) => {
+8 -7
View File
@@ -350,7 +350,7 @@ export class SessionManager {
this.sessions.delete(sessionDbId);
this.sessionQueues.delete(sessionDbId);
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
logger.info('SESSION', 'Session removed from active sessions', {
sessionId: sessionDbId,
project: session.project
});
@@ -402,10 +402,11 @@ export class SessionManager {
}
/**
* Check if any session has pending messages (for spinner tracking)
* Check if any active session has pending messages (for spinner tracking).
* Scoped to in-memory sessions only.
*/
hasPendingMessages(): boolean {
return this.getPendingStore().hasAnyPendingWork();
return this.getTotalQueueDepth() > 0;
}
/**
@@ -437,12 +438,12 @@ export class SessionManager {
}
/**
* Check if any session is actively processing (has pending messages OR active generator)
* Used for activity indicator to prevent spinner from stopping while SDK is processing
* Check if any active session has pending work.
* Scoped to in-memory sessions only orphaned DB messages from dead
* sessions must not keep the spinner spinning forever.
*/
isAnySessionProcessing(): boolean {
// hasAnyPendingWork checks for 'pending' OR 'processing'
return this.getPendingStore().hasAnyPendingWork();
return this.getTotalQueueDepth() > 0;
}
/**
@@ -33,12 +33,6 @@ export class SessionEventBroadcaster {
prompt
});
// Start activity indicator (work is about to begin)
this.sseBroadcaster.broadcast({
type: 'processing_status',
isProcessing: true
});
// Update processing status based on queue depth
this.workerService.broadcastProcessingStatus();
}
+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');
});
});
+148
View File
@@ -326,4 +326,152 @@ describe('Zombie Agent Prevention', () => {
session.generatorPromise = null;
expect(session.generatorPromise).toBeNull();
});
describe('Session Termination Invariant', () => {
// Tests the restart-or-terminate invariant:
// When a generator exits without restarting, its messages must be
// marked abandoned and the session removed from the active Map.
test('should mark messages abandoned when session is terminated', () => {
const sessionId = createDbSession('content-terminate-1');
enqueueTestMessage(sessionId, 'content-terminate-1');
enqueueTestMessage(sessionId, 'content-terminate-1');
// Verify messages exist
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
expect(pendingStore.hasAnyPendingWork()).toBe(true);
// Terminate: mark abandoned (same as terminateSession does)
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(2);
// Spinner should stop: no pending work remains
expect(pendingStore.hasAnyPendingWork()).toBe(false);
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
});
test('should handle terminate with zero pending messages', () => {
const sessionId = createDbSession('content-terminate-empty');
// No messages enqueued
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
// Terminate with nothing to abandon
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(0);
// Still no pending work
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should be idempotent — double terminate marks zero on second call', () => {
const sessionId = createDbSession('content-terminate-idempotent');
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
// First terminate
const first = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(first).toBe(1);
// Second terminate — already failed, nothing to mark
const second = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(second).toBe(0);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should remove session from Map via removeSessionImmediate', () => {
const sessionId = createDbSession('content-terminate-map');
const session = createMockSession(sessionId, {
contentSessionId: 'content-terminate-map',
});
// Simulate the in-memory sessions Map
const sessions = new Map<number, ActiveSession>();
sessions.set(sessionId, session);
expect(sessions.has(sessionId)).toBe(true);
// Simulate removeSessionImmediate behavior
sessions.delete(sessionId);
expect(sessions.has(sessionId)).toBe(false);
});
test('should return hasAnyPendingWork false after all sessions terminated', () => {
// Create multiple sessions with messages
const sid1 = createDbSession('content-multi-term-1');
const sid2 = createDbSession('content-multi-term-2');
const sid3 = createDbSession('content-multi-term-3');
enqueueTestMessage(sid1, 'content-multi-term-1');
enqueueTestMessage(sid1, 'content-multi-term-1');
enqueueTestMessage(sid2, 'content-multi-term-2');
enqueueTestMessage(sid3, 'content-multi-term-3');
expect(pendingStore.hasAnyPendingWork()).toBe(true);
// Terminate all sessions
pendingStore.markAllSessionMessagesAbandoned(sid1);
pendingStore.markAllSessionMessagesAbandoned(sid2);
pendingStore.markAllSessionMessagesAbandoned(sid3);
// Spinner must stop
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should not affect other sessions when terminating one', () => {
const sid1 = createDbSession('content-isolate-1');
const sid2 = createDbSession('content-isolate-2');
enqueueTestMessage(sid1, 'content-isolate-1');
enqueueTestMessage(sid2, 'content-isolate-2');
// Terminate only session 1
pendingStore.markAllSessionMessagesAbandoned(sid1);
// Session 2 still has work
expect(pendingStore.getPendingCount(sid1)).toBe(0);
expect(pendingStore.getPendingCount(sid2)).toBe(1);
expect(pendingStore.hasAnyPendingWork()).toBe(true);
});
test('should mark both pending and processing messages as abandoned', () => {
const sessionId = createDbSession('content-mixed-status');
// Enqueue two messages
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
enqueueTestMessage(sessionId, 'content-mixed-status');
// Claim first message (transitions to 'processing')
const claimed = pendingStore.claimNextMessage(sessionId);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(msgId1);
// Now we have 1 processing + 1 pending
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
// Terminate should mark BOTH as failed
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(2);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should enforce invariant: no pending work after terminate regardless of initial state', () => {
const sessionId = createDbSession('content-invariant');
// Create a complex initial state: some pending, some processing, some with stale timestamps
enqueueTestMessage(sessionId, 'content-invariant');
enqueueTestMessage(sessionId, 'content-invariant');
enqueueTestMessage(sessionId, 'content-invariant');
// Claim one (processing)
pendingStore.claimNextMessage(sessionId);
// Verify complex state
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
// THE INVARIANT: after terminate, hasAnyPendingWork MUST be false
pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
});
});
});