Skip to content
6 changes: 5 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ function nativeModuleStub(): Plugin {
const STUB_ID = '\0native-stub'
return {
name: 'native-module-stub',
enforce: 'pre',
resolveId(source) {
// Don't stub our native JSONL parser — it's loaded dynamically at runtime
if (source.includes('claude-devtools-native')) return null
if (source.endsWith('.node')) return STUB_ID
Comment on lines +20 to 24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any imports or requires that might contain 'claude-devtools-native'
echo "=== Searching for claude-devtools-native imports/requires ==="
rg -n "claude-devtools-native" --type=ts --type=js

echo ""
echo "=== Checking package.json for native module references ==="
rg -n "claude-devtools-native" package.json || echo "Not found in package.json"

Repository: matt1398/claude-devtools

Length of output: 307


Remove or explain the unused 'claude-devtools-native' exemption.

Verification confirms this exemption is never triggered. The string 'claude-devtools-native' appears only in the exemption check itself and is not imported or required anywhere in the codebase or referenced in package.json. The actual native module loading occurs at runtime via dynamic require(candidate) with filesystem paths, which bypass this vite plugin entirely. This check appears to be dead code and should be removed unless it's intentionally reserved for future use (in which case, add a clarifying comment).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron.vite.config.ts` around lines 20 - 24, The resolveId hook contains a
dead exemption for the literal 'claude-devtools-native' that is never imported
or used; remove the if-check that returns null for
source.includes('claude-devtools-native') to eliminate dead code in the
resolveId function, or if you intend to reserve this behavior, replace it with a
concise comment above resolveId explaining why the exemption exists and when it
should be re-enabled; reference the resolveId function and the string
'claude-devtools-native' when making the change so reviewers can verify the
intent.

return null
},
Expand Down Expand Up @@ -47,7 +50,8 @@ export default defineConfig({
outDir: 'dist-electron/main',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts')
index: resolve(__dirname, 'src/main/index.ts'),
sessionParseWorker: resolve(__dirname, 'src/main/workers/sessionParseWorker.ts')
},
output: {
// CJS format so bundled deps can use __dirname/require.
Expand Down
21 changes: 17 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { join } from 'path';

import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
import { sessionParserPool } from './workers/SessionParserPool';

// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
Expand Down Expand Up @@ -400,6 +401,9 @@ function shutdownServices(): void {
sshConnectionManager.dispose();
}

// Terminate worker pool
sessionParserPool.terminate();

// Remove IPC handlers
removeIpcHandlers();

Expand Down Expand Up @@ -481,12 +485,21 @@ function createWindow(): void {
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;

if (input.type !== 'keyDown') return;

// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
if ((input.control || input.meta) && input.key.toLowerCase() === 'r') {
// Intercept Ctrl+R / Cmd+R to prevent Chromium's built-in page reload,
// then notify the renderer via IPC so it can refresh the session (fixes #58, #85).
// We must preventDefault here because Chromium handles Ctrl+R at the browser
// engine level, which also blocks the keydown from reaching the renderer —
// hence the IPC bridge.
if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
mainWindow.webContents.send('session:refresh');
return;
}
Comment on lines +496 to +500
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use the SESSION_REFRESH constant instead of hardcoded string.

Line 498 uses the hardcoded string 'session:refresh' while src/preload/index.ts imports and uses the SESSION_REFRESH constant from ipcChannels.ts. This inconsistency could lead to silent bugs if the channel name changes.

♻️ Proposed fix

Import the constant at the top of the file (around line 48 where other IPC constants are duplicated):

 const SSH_STATUS = 'ssh:status';
 const CONTEXT_CHANGED = 'context:changed';
+const SESSION_REFRESH = 'session:refresh';

Then use it in the handler:

     if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
       event.preventDefault();
-      mainWindow.webContents.send('session:refresh');
+      mainWindow.webContents.send(SESSION_REFRESH);
       return;
     }

Based on learnings: "IPC channel constants must be defined in preload/constants/ipcChannels.ts"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
mainWindow.webContents.send('session:refresh');
return;
}
if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
mainWindow.webContents.send(SESSION_REFRESH);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/index.ts` around lines 496 - 500, Replace the hardcoded IPC channel
string with the exported constant: add an import for SESSION_REFRESH (from the
same ipc channel module used elsewhere) at the top of the file near the other
IPC constants, then change the call
mainWindow.webContents.send('session:refresh') to use SESSION_REFRESH instead;
keep the rest of the handler (event.preventDefault, return) unchanged so the
behavior remains the same.

// Also block Ctrl+Shift+R (hard reload)
if ((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
return;
}
Expand Down
143 changes: 115 additions & 28 deletions src/main/ipc/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type SessionsByIdsOptions,
type SessionsPaginationOptions,
} from '../types';
import { sessionParserPool } from '../workers/SessionParserPool';

import { coercePageLimit, validateProjectId, validateSessionId } from './guards';

Expand All @@ -33,6 +34,9 @@ const logger = createLogger('IPC:sessions');
// Service registry - set via initialize
let registry: ServiceContextRegistry;

// Sessions where native pipeline produced invalid chunks — permanently use JS fallback
const nativeDisabledSessions = new Set<string>();

/**
* Initializes session handlers with service registry.
*/
Expand Down Expand Up @@ -67,6 +71,9 @@ export function removeSessionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-session-metrics');
ipcMain.removeHandler('get-waterfall-data');

// Release accumulated per-session state
nativeDisabledSessions.clear();

logger.info('Session handlers removed');
}

Expand Down Expand Up @@ -219,40 +226,120 @@ async function handleGetSessionDetail(

// Check cache first
let sessionDetail = dataCache.get(cacheKey);
let usedNative = false;

if (!sessionDetail) {
const fsType = projectScanner.getFileSystemProvider().type;
// In SSH mode, avoid an extra deep metadata scan before full parse.
const session = await projectScanner.getSessionWithOptions(safeProjectId, safeSessionId, {
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
});
if (!session) {
logger.error(`Session not found: ${sessionId}`);
return null;
}

// Try native Rust pipeline (local filesystem only).
// Rust handles: JSONL read -> classify -> chunk -> tool executions -> semantic steps.
// Returns serde_json::Value with exact TS field names. JS only converts timestamps.
// JS still handles: subagent resolution (requires filesystem provider).
// Use native Rust pipeline for local sessions WITHOUT subagents.
// Sessions with subagents need ProcessLinker + sidechain context which
// only the JS pipeline provides (Review finding #1).
const hasSubagentFiles = await projectScanner.hasSubagents(
safeProjectId,
safeSessionId
);
if (fsType === 'local' && !hasSubagentFiles && !nativeDisabledSessions.has(cacheKey)) {
try {
const { buildSessionChunksNative } = await import('../utils/nativeJsonl');
const sessionPath = projectScanner.getSessionPath(safeProjectId, safeSessionId);
const nativeResult = buildSessionChunksNative(sessionPath);
// Validate ALL chunks — not just the first. If any chunk has wrong
// shape, fall back to JS pipeline instead of sending bad data to renderer.
const isValidNative =
nativeResult &&
nativeResult.chunks.length > 0 &&
(nativeResult.chunks as Record<string, unknown>[]).every(
(c) =>
c != null &&
typeof c.chunkType === 'string' &&
'rawMessages' in c &&
'startTime' in c &&
'metrics' in c
);

if (isValidNative) {
sessionDetail = {
session,
messages: [],
chunks: nativeResult.chunks as SessionDetail['chunks'],
processes: [],
metrics: nativeResult.metrics as SessionDetail['metrics'],
};
usedNative = true;
} else if (nativeResult) {
// Native produced chunks but they failed validation — permanently
// disable native for this session to avoid repeated failures.
logger.warn(`Native validation failed for ${cacheKey}, disabling native for this session`);
nativeDisabledSessions.add(cacheKey);
}
} catch {
// Native not available — fall through to JS
}
}

if (sessionDetail) {
return sessionDetail;
// JS fallback pipeline — dispatch to Worker Thread to avoid blocking main process
if (!usedNative) {
try {
sessionDetail = await sessionParserPool.parse({
projectsDir: projectScanner.getProjectsDir(),
sessionPath: projectScanner.getSessionPath(safeProjectId, safeSessionId),
projectId: safeProjectId,
sessionId: safeSessionId,
fsType,
session,
});
} catch (workerError) {
// Worker failed (timeout, crash, etc.) — fall back to inline blocking parse
logger.warn('Worker parse failed, falling back to inline:', workerError);
const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId);
const subagents = await subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
session.hasSubagents = subagents.length > 0;
sessionDetail = chunkBuilder.buildSessionDetail(
session,
parsedSession.messages,
subagents
);
}
}

// Cache JS pipeline results only — native results skip cache so any
// rendering failures on the next request will fall back to JS pipeline.
if (sessionDetail && !usedNative) {
dataCache.set(cacheKey, sessionDetail);
}
}

const fsType = projectScanner.getFileSystemProvider().type;
// In SSH mode, avoid an extra deep metadata scan before full parse.
const session = await projectScanner.getSessionWithOptions(safeProjectId, safeSessionId, {
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
});
if (!session) {
logger.error(`Session not found: ${sessionId}`);
if (!sessionDetail) {
return null;
}

// Parse session messages
const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId);

// Resolve subagents
const subagents = await subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
session.hasSubagents = subagents.length > 0;

// Build session detail with chunks
sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents);

// Cache the result
dataCache.set(cacheKey, sessionDetail);

return sessionDetail;
// Strip raw messages before IPC transfer — the renderer never uses them.
// Only chunks (with semantic steps) and process summaries cross the boundary.
// This cuts IPC serialization + renderer heap by ~50-60%.
return {
...sessionDetail,
messages: [],
processes: sessionDetail.processes.map((p) => ({ ...p, messages: [] })),
// Only report native pipeline when Rust actually handled full chunking.
_nativePipeline: usedNative ? Date.now() : false,
};
} catch (error) {
logger.error(`Error in get-session-detail for ${projectId}/${sessionId}:`, error);
return null;
Expand Down
2 changes: 2 additions & 0 deletions src/main/types/chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ export interface SessionDetail {
processes: Process[];
/** Aggregated metrics for the entire session */
metrics: SessionMetrics;
/** Timestamp (ms) when Rust native pipeline was used, or false if JS fallback */
_nativePipeline?: number | false;
}

/**
Expand Down
Loading