Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');

Expand Down Expand Up @@ -73,7 +73,7 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, touchCachedModified,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
Expand All @@ -91,7 +91,7 @@
const activeSessions = new Map();
let mainWindow = null;

// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId })
// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId, teardown })
const subagentWatchers = new Map();
let subagentWatcherSeq = 0;

Expand Down Expand Up @@ -221,9 +221,10 @@
}
activeSessions.delete(id);
}
// Release all subagent file watchers
// Release all subagent file watchers (closes fs.watch handles + clears any
// debounce timers / polling fallbacks via the stored teardown closure)
for (const [, entry] of subagentWatchers) {
try { fs.unwatchFile(entry.filePath); } catch {}
try { entry.teardown(); } catch {}
}
subagentWatchers.clear();
mainWindow = null;
Expand Down Expand Up @@ -289,8 +290,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 294 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 294 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


Expand Down Expand Up @@ -1298,18 +1299,51 @@
} catch {}
}

// fs.watchFile gives reliable polling on Linux where inotify can be unreliable for JSONL appends
fs.watchFile(filePath, { interval: 1000, persistent: false }, readNewEntries);
// Coalesce rapid JSONL appends into a single incremental read. Mirrors the
// debounce used by the projects watcher (see startProjectsWatcher) instead of
// polling stat() once per second per watcher, which pegged the main process at
// idle when watchers accumulated.
let debounceTimer = null;
function scheduleRead() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
readNewEntries();
}, 300);
}

let watcher = null;
let pollInterval = null;
try {
watcher = fs.watch(filePath, { persistent: false }, (eventType) => {
if (eventType === 'rename') return; // file replaced/removed — ignore
scheduleRead();
});
watcher.on('error', (err) => {
log.warn(`[subagent-watch] fs.watch error watchId=${watchId}: ${err.message}`);
});
} catch (err) {
// Robustness fallback only when fs.watch can't attach: poll on a long
// interval (10s) so a failed inotify registration never busy-stats the file.
log.warn(`[subagent-watch] fs.watch failed watchId=${watchId}, polling fallback: ${err.message}`);
pollInterval = setInterval(readNewEntries, 10000);
}

function teardown() {
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
if (watcher) { try { watcher.close(); } catch {} watcher = null; }
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}

subagentWatchers.set(watchId, { filePath, parentSessionId, agentId });
subagentWatchers.set(watchId, { filePath, parentSessionId, agentId, teardown });
log.info(`[subagent-watch] start watchId=${watchId} parent=${parentSessionId} agentId=${agentId}`);
return { watchId };
});

ipcMain.handle('stop-subagent-watch', (_event, watchId) => {
const entry = subagentWatchers.get(watchId);
if (!entry) return { ok: false };
fs.unwatchFile(entry.filePath);
entry.teardown();
subagentWatchers.delete(watchId);
log.info(`[subagent-watch] stop watchId=${watchId}`);
return { ok: true };
Expand Down Expand Up @@ -1369,7 +1403,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1406 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1406 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
20 changes: 19 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,13 +594,30 @@ terminalStopBtn.addEventListener('click', () => {


// --- Poll for active PTY sessions ---
// Adaptive cadence: poll fast (3s) only while PTYs are running; when idle, back
// off to 30s. Every renderer path that starts a session (launchNewSession,
// openSession, launchTerminalSession, onSessionDetected/Forked) calls
// pollActiveSessions() explicitly, which re-arms the fast cadence immediately.
// The 30s idle floor still catches sessions started outside the renderer
// (scheduler-spawned PTYs, other windows) within at most 30s.
const POLL_FAST_MS = 3000;
const POLL_IDLE_MS = 30000;
let pollTimer = null;

function scheduleActiveSessionsPoll() {
if (pollTimer) clearTimeout(pollTimer);
const delay = activePtyIds.size > 0 ? POLL_FAST_MS : POLL_IDLE_MS;
pollTimer = setTimeout(pollActiveSessions, delay);
}

async function pollActiveSessions() {
try {
const ids = await window.api.getActiveSessions();
activePtyIds = new Set(ids);
updateRunningIndicators();
updateTerminalHeader();
} catch {}
scheduleActiveSessionsPoll();
}

function updateRunningIndicators() {
Expand Down Expand Up @@ -655,10 +672,11 @@ function updatePtyTitle() {
terminalHeaderPtyTitle.style.display = title ? '' : 'none';
}

setInterval(pollActiveSessions, 3000);
scheduleActiveSessionsPoll();

// Refresh sidebar timeago labels every 30s so "just now" ticks forward
setInterval(() => {
if (lastActivityTime.size === 0) return;
for (const [sessionId, time] of lastActivityTime) {
const item = document.getElementById('si-' + sessionId);
if (!item) continue;
Expand Down
61 changes: 47 additions & 14 deletions schedule-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,44 @@ function cronMatches(cronExpr, now) {
);
}

/**
* Resolve a project folder name to its project path from the SQLite cache.
* Returns a Map<folder, projectPath>, or an empty Map if the cache is
* unavailable (e.g. in tests that don't load the native DB binding).
*/
function loadFolderMetaMap() {
try {
// Lazy require so requiring schedule-runner.js never forces the native
// better-sqlite3 binding to load (keeps the module test-friendly).
const { getAllFolderMeta } = require('./db');
const meta = getAllFolderMeta();
const map = new Map();
for (const [folder, row] of meta) {
if (row && row.projectPath) map.set(folder, row.projectPath);
}
return map;
} catch {
return new Map();
}
}

/** Read a project folder's first JSONL just enough to extract its cwd. */
function readProjectPathFromJsonl(folderPath) {
try {
const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl'));
for (const jf of jsonlFiles) {
const head = fs.readFileSync(path.join(folderPath, jf), 'utf8').slice(0, 4000);
for (const line of head.split('\n').filter(Boolean)) {
try {
const entry = JSON.parse(line);
if (entry.cwd) return entry.cwd;
} catch {}
}
}
} catch {}
return null;
}

/** Scan all projects for schedule-*.md files and return parsed schedule objects. */
function scanSchedules(log) {
const schedules = [];
Expand All @@ -82,22 +120,17 @@ function scanSchedules(log) {
const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory());

// Prefer the cached folder→projectPath mapping; only read JSONLs for
// folders genuinely missing from the cache. This avoids re-reading 4KB of
// every JSONL of every project on each 60s tick.
const folderMeta = loadFolderMetaMap();

for (const folder of folders) {
const folderPath = path.join(PROJECTS_DIR, folder.name);
let projectPath = null;
try {
const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl'));
for (const jf of jsonlFiles) {
const head = fs.readFileSync(path.join(folderPath, jf), 'utf8').slice(0, 4000);
for (const line of head.split('\n').filter(Boolean)) {
try {
const entry = JSON.parse(line);
if (entry.cwd) { projectPath = entry.cwd; break; }
} catch {}
}
if (projectPath) break;
}
} catch {}
let projectPath = folderMeta.get(folder.name) || null;
if (!projectPath) {
projectPath = readProjectPathFromJsonl(folderPath);
}
if (!projectPath) continue;

const commandsDir = path.join(projectPath, '.claude', 'commands');
Expand Down
Loading