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:
@@ -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
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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
+107
-116
File diff suppressed because one or more lines are too long
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user