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