Skip to content

Commit a170dcc

Browse files
Commit pending core changes (fix CI type errors)
Previously uncommitted changes to platform, sessions, processes, projects, activity, tailer, and ConversationPane that are referenced by already-committed code. Fixes CI type check failures for missing exports: copyToClipboard, getRecentSessionsWithChildren. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c6ba14e commit a170dcc

File tree

7 files changed

+259
-21
lines changed

7 files changed

+259
-21
lines changed

packages/cli/src/core/activity.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export async function parseSessionActivity(filePath: string): Promise<SessionAct
6262
let firstTimestamp: number | null = null;
6363
let lastTimestamp: number | null = null;
6464
let lastWasAssistant = false;
65+
const now = Date.now();
66+
const hourlyWindowMs = 5 * 60 * 60_000; // 5h — matches ACTIVE_THRESHOLD_MS
6567

6668
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
6769
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
@@ -77,7 +79,9 @@ export async function parseSessionActivity(filePath: string): Promise<SessionAct
7779
if (!isNaN(ts)) {
7880
if (firstTimestamp === null) firstTimestamp = ts;
7981
lastTimestamp = ts;
80-
activity.hourlyActivity[new Date(ts).getHours()]++;
82+
if (now - ts <= hourlyWindowMs) {
83+
activity.hourlyActivity[new Date(ts).getHours()]++;
84+
}
8185
}
8286
}
8387

packages/cli/src/core/platform.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ export function isTTY(): boolean {
103103
return !!process.stdout.isTTY;
104104
}
105105

106+
/**
107+
* Copy text to the system clipboard. Cross-platform.
108+
*/
109+
export function copyToClipboard(text: string): boolean {
110+
try {
111+
switch (getPlatform()) {
112+
case 'windows':
113+
execFileSync('clip', { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 3000 });
114+
return true;
115+
case 'macos':
116+
execFileSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 3000 });
117+
return true;
118+
default:
119+
execFileSync('xclip', ['-selection', 'clipboard'], { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 3000 });
120+
return true;
121+
}
122+
} catch { return false; }
123+
}
124+
106125
/**
107126
* Open a path in the system file explorer.
108127
*/

packages/cli/src/core/processes.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
* Multiple sessions per project are supported.
1313
*/
1414

15+
import fs from 'node:fs';
1516
import { getRecentSessionFiles } from './projects.js';
1617
import { normalizePathForCompare } from './platform.js';
1718
import { log } from './logger.js';
18-
import { readSessionMarkers } from './launcher.js';
19+
import { readSessionMarkers, getMarkerPath } from './launcher.js';
1920
import { getTrackedSessions, isProcessRunning } from './tracker.js';
2021
import type { ActiveSession, SessionActivity } from '../types.js';
2122

@@ -56,17 +57,54 @@ export async function getActiveClaudeProcesses(
5657
const coveredPaths = new Set<string>();
5758

5859
// 1. Session markers (cross-platform — files exist while Claude is running)
60+
// Dedup by project path (keep newest marker), auto-clean stale markers.
5961
try {
60-
const markers = readSessionMarkers();
61-
for (const m of markers) {
62+
const allMarkers = readSessionMarkers();
63+
64+
// Group markers by normalized project path, keep only the newest per project
65+
const markersByProject = new Map<string, typeof allMarkers[0]>();
66+
const staleMarkerIds: string[] = [];
67+
for (const m of allMarkers) {
68+
const np = normalizePathForCompare(m.projectPath);
69+
const existing = markersByProject.get(np);
70+
if (existing) {
71+
// Keep newer, mark older for cleanup
72+
if (m.launchTime > existing.launchTime) {
73+
staleMarkerIds.push(existing.launchId);
74+
markersByProject.set(np, m);
75+
} else {
76+
staleMarkerIds.push(m.launchId);
77+
}
78+
} else {
79+
markersByProject.set(np, m);
80+
}
81+
}
82+
83+
// Clean up duplicate markers (best-effort, don't block on errors)
84+
for (const id of staleMarkerIds) {
85+
try { fs.unlinkSync(getMarkerPath(id)); } catch { /* ignore */ }
86+
}
87+
88+
for (const m of markersByProject.values()) {
6289
const np = normalizePathForCompare(m.projectPath);
63-
coveredPaths.add(np);
6490

6591
// Find the JSONL file for this session
6692
const recentFiles = getRecentSessionFiles(m.projectPath, now - m.launchTime + 60_000);
6793
const sessionFile = recentFiles.find(f => !claimedFiles.has(f.filePath)) ?? recentFiles[0] ?? null;
6894
const isRecentJsonl = sessionFile && (now - sessionFile.mtimeMs) < IDLE_THRESHOLD_MS;
6995

96+
// Auto-clean stale markers: marker is old AND JSONL is idle (not actively being written).
97+
// A marker >5h old with >5min JSONL silence is almost certainly a dead session whose
98+
// cleanup script didn't run (terminal closed, crash, etc.). The session will reappear
99+
// via mtime detection if activity resumes.
100+
const markerAge = now - m.launchTime;
101+
if (markerAge > ACTIVE_THRESHOLD_MS && !isRecentJsonl) {
102+
try { fs.unlinkSync(getMarkerPath(m.launchId)); } catch { /* ignore */ }
103+
log('info', { function: 'getActiveClaudeProcesses', message: `Cleaned stale marker: ${m.launchId} (${m.projectPath})` });
104+
continue; // Don't create a session for a stale marker
105+
}
106+
107+
coveredPaths.add(np);
70108
if (sessionFile) claimedFiles.add(sessionFile.filePath);
71109

72110
sessions.push({

packages/cli/src/core/projects.ts

Lines changed: 127 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,119 @@ function getProjectPathFromSlug(slugDir: string): string | null {
268268
return result;
269269
}
270270

271+
// ── Child slug directory discovery ───────────────────────────
272+
273+
/**
274+
* Find child session directories (subfolders) for a parent project.
275+
* Uses slug prefix as a fast filter, then verifies via path comparison.
276+
* Relies on slugToPathCache being warm (populated by discoverProjects).
277+
*/
278+
export function findChildSlugDirs(parentPath: string, excludePaths?: Set<string>): Array<{
279+
slugDir: string; childPath: string; relativePath: string;
280+
}> {
281+
const claudeDir = getClaudeProjectsDir();
282+
if (!fs.existsSync(claudeDir)) return [];
283+
284+
const parentSlug = getProjectSlug(parentPath);
285+
const parentNorm = normalizePathForCompare(parentPath);
286+
const sep = path.sep.toLowerCase();
287+
const results: Array<{ slugDir: string; childPath: string; relativePath: string }> = [];
288+
289+
let entries: fs.Dirent[];
290+
try {
291+
entries = fs.readdirSync(claudeDir, { withFileTypes: true });
292+
} catch {
293+
return [];
294+
}
295+
296+
for (const entry of entries) {
297+
if (!entry.isDirectory()) continue;
298+
// Fast filter: slug must start with parentSlug + '-'
299+
if (!entry.name.startsWith(parentSlug + '-')) continue;
300+
// Skip the parent itself
301+
if (entry.name === parentSlug) continue;
302+
303+
const slugDir = path.join(claudeDir, entry.name);
304+
const childPath = getProjectPathFromSlug(slugDir);
305+
if (!childPath) continue;
306+
307+
// Verify child is actually under parent (prevents false positives like CageMetrics-v2)
308+
const childNorm = normalizePathForCompare(childPath);
309+
if (!childNorm.startsWith(parentNorm + sep)) continue;
310+
311+
// Skip pinned subfolders
312+
if (excludePaths) {
313+
const childKey = normalizePathForCompare(childPath);
314+
if (excludePaths.has(childKey)) continue;
315+
}
316+
317+
const relativePath = path.relative(parentPath, childPath);
318+
results.push({ slugDir, childPath, relativePath });
319+
}
320+
321+
results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
322+
return results;
323+
}
324+
325+
// ── Name disambiguation ─────────────────────────────────────
326+
327+
/**
328+
* When multiple projects share the same display name, append the
329+
* distinguishing parent folder so they're visually distinct.
330+
* e.g. two "CageMetrics" → "CageMetrics" and "CageMetrics/data"
331+
*/
332+
function disambiguateNames(projects: Project[]): void {
333+
// Group by lowercase name to find collisions
334+
const byName = new Map<string, Project[]>();
335+
for (const p of projects) {
336+
const key = p.name.toLowerCase();
337+
const group = byName.get(key);
338+
if (group) group.push(p);
339+
else byName.set(key, [p]);
340+
}
341+
342+
for (const group of byName.values()) {
343+
if (group.length < 2) continue;
344+
// Append the last unique path segment to each duplicate
345+
for (const p of group) {
346+
const parts = p.path.replace(/\\/g, '/').split('/').filter(Boolean);
347+
// Use last 2 segments: "parent/child" (or just folder name if only 1 segment)
348+
const suffix = parts.length >= 2
349+
? `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
350+
: parts[parts.length - 1] || p.path;
351+
p.name = suffix;
352+
}
353+
}
354+
}
355+
356+
// ── Nested project filtering ────────────────────────────────
357+
358+
/**
359+
* Remove non-pinned projects whose path is a strict subdirectory of another
360+
* project in the list. Mutates the array in place.
361+
*
362+
* Projects can arrive from three sources (pinned, discovered, indexed) in any
363+
* order, so per-source checks miss cross-source nesting (e.g., a discovered
364+
* child session dir + an indexed parent repo). This single final pass handles
365+
* all combinations.
366+
*/
367+
function filterNestedProjects(projects: Project[]): void {
368+
const sep = path.sep.toLowerCase();
369+
const allPaths = new Set(projects.map(p => normalizePathForCompare(p.path)));
370+
371+
for (let i = projects.length - 1; i >= 0; i--) {
372+
if (projects[i].pinned) continue; // pinned subfolders stay standalone
373+
const key = normalizePathForCompare(projects[i].path);
374+
for (const other of allPaths) {
375+
if (other !== key && key.startsWith(other + sep)) {
376+
projects.splice(i, 1);
377+
allPaths.delete(key);
378+
break;
379+
}
380+
}
381+
}
382+
}
383+
271384
// ── List building ───────────────────────────────────────────
272385

273386
/**
@@ -322,18 +435,11 @@ export function buildProjectList(config: Config): Project[] {
322435
}
323436

324437
// Add indexed projects (from previous filesystem scans) — these may not have Claude sessions yet
325-
// Skip entries that are subdirectories of already-seen projects (stale index data)
326438
const indexed = readProjectIndex();
327439
for (const entry of indexed) {
328440
const key = normalizePathForCompare(entry.path);
329441
if (seenPaths.has(key)) continue;
330442
if (hiddenSet.has(key)) continue;
331-
// Skip if this path is a child of an already-seen project
332-
let isSubdir = false;
333-
for (const seen of seenPaths) {
334-
if (key.startsWith(seen + path.sep.toLowerCase())) { isSubdir = true; break; }
335-
}
336-
if (isSubdir) continue;
337443
seenPaths.add(key);
338444

339445
let displayName: string;
@@ -349,6 +455,14 @@ export function buildProjectList(config: Config): Project[] {
349455
});
350456
}
351457

458+
// Final pass: remove non-pinned projects whose path is a subdirectory of another project.
459+
// This catches cross-source nesting (e.g., discovered child + indexed parent) regardless
460+
// of which source added each project or in what order.
461+
filterNestedProjects(projects);
462+
463+
// Disambiguate duplicate display names by appending parent folder
464+
disambiguateNames(projects);
465+
352466
// Persist name cache for fast mini TUI lookups
353467
try { writeProjectNameCache(nameCache); } catch {}
354468

@@ -409,17 +523,11 @@ export function buildProjectListFast(config: Config): Project[] {
409523
}
410524

411525
// Add indexed projects (from previous filesystem scans)
412-
// Skip entries that are subdirectories of already-seen projects
413526
const indexed = readProjectIndex();
414527
for (const entry of indexed) {
415528
const key = normalizePathForCompare(entry.path);
416529
if (seenPaths.has(key)) continue;
417530
if (hiddenSet.has(key)) continue;
418-
let isSubdir = false;
419-
for (const seen of seenPaths) {
420-
if (key.startsWith(seen + path.sep.toLowerCase())) { isSubdir = true; break; }
421-
}
422-
if (isSubdir) continue;
423531
seenPaths.add(key);
424532

425533
const displayName = nameCache[entry.path] ?? entry.name;
@@ -437,6 +545,12 @@ export function buildProjectListFast(config: Config): Project[] {
437545
});
438546
}
439547

548+
// Final pass: remove non-pinned projects whose path is a subdirectory of another project.
549+
filterNestedProjects(projects);
550+
551+
// Disambiguate duplicate display names by appending parent folder
552+
disambiguateNames(projects);
553+
440554
if (cacheUpdated) {
441555
try { writeProjectNameCache(nameCache); } catch {}
442556
}

packages/cli/src/core/sessions.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import fs from 'node:fs';
77
import path from 'node:path';
88
import readline from 'node:readline';
99
import { DEFAULTS } from '../constants.js';
10-
import { getSessionDir } from './projects.js';
10+
import { getSessionDir, findChildSlugDirs } from './projects.js';
1111
import { getConfigDir } from '../config.js';
1212
import { log } from './logger.js';
1313
import { loadSummaryCache } from './summaries.js';
@@ -371,6 +371,67 @@ export async function getRecentSessions(
371371
}
372372
}
373373

374+
// ── Merged sessions (parent + child subfolders) ──────────────
375+
376+
// Separate cache for merged sessions (different key semantics than sessionCache)
377+
const mergedSessionCache = new Map<string, { sessions: Session[]; fetchedAt: number }>();
378+
379+
/**
380+
* Get recent sessions for a project plus all its child subfolders, merged chronologically.
381+
* Child sessions are tagged with subfolder and projectPath for display and resume.
382+
*/
383+
export async function getRecentSessionsWithChildren(
384+
projectPath: string,
385+
maxSessions: number = DEFAULTS.maxSessions,
386+
excludeChildPaths?: Set<string>,
387+
): Promise<Session[]> {
388+
const cacheKey = projectPath;
389+
const cached = mergedSessionCache.get(cacheKey);
390+
if (cached && Date.now() - cached.fetchedAt < SESSION_CACHE_TTL) {
391+
return cached.sessions.slice(0, maxSessions);
392+
}
393+
394+
try {
395+
// Get parent sessions (uses its own 30s cache)
396+
const parentSessions = await getRecentSessions(projectPath, maxSessions);
397+
398+
// Find child slug directories (cap at 8 to bound I/O)
399+
const allChildren = findChildSlugDirs(projectPath, excludeChildPaths);
400+
const children = allChildren.slice(0, 8);
401+
if (children.length === 0) {
402+
// No children — cache and return parent sessions as-is
403+
mergedSessionCache.set(cacheKey, { sessions: parentSessions, fetchedAt: Date.now() });
404+
return parentSessions.slice(0, maxSessions);
405+
}
406+
407+
// Fetch child sessions in parallel, loading only a few per child.
408+
// We only need enough to fill the merged list (maxSessions total).
409+
const perChild = Math.max(5, Math.ceil(maxSessions / (children.length + 1)));
410+
const childResults = await Promise.all(
411+
children.map(async (child) => {
412+
const sessions = await getRecentSessions(child.childPath, perChild);
413+
return sessions.map(s => ({
414+
...s,
415+
subfolder: child.relativePath,
416+
projectPath: child.childPath,
417+
}));
418+
}),
419+
);
420+
421+
// Merge all into one array, sort by modified descending, cap at maxSessions
422+
const allSessions = [...parentSessions, ...childResults.flat()];
423+
allSessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
424+
const result = allSessions.slice(0, maxSessions);
425+
426+
mergedSessionCache.set(cacheKey, { sessions: result, fetchedAt: Date.now() });
427+
return result;
428+
} catch (err) {
429+
log('error', { function: 'getRecentSessionsWithChildren', message: String(err) });
430+
// Fall back to parent-only sessions
431+
return getRecentSessions(projectPath, maxSessions);
432+
}
433+
}
434+
374435
// ── Session preview (first N user messages) ─────────────────
375436

376437
// ── Rolling usage stats (5-hour window) ─────────────────────

packages/cli/src/core/tailer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ function processLine(line: string, state: TailerInternal): void {
126126
if (!isNaN(ts)) {
127127
if (state.firstTimestamp === null) state.firstTimestamp = ts;
128128
state.lastTimestamp = ts;
129-
state.activity.hourlyActivity[new Date(ts).getHours()]++;
129+
if (Date.now() - ts <= 5 * 60 * 60_000) { // 5h window
130+
state.activity.hourlyActivity[new Date(ts).getHours()]++;
131+
}
130132
}
131133
}
132134

packages/cli/src/tui/components/ConversationPane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const ConversationPane = React.memo(function ConversationPane({
8282
const tok = formatTokenCount(session.stats.tokens);
8383

8484
return (
85-
<Box key={session.projectPath} paddingX={1}>
85+
<Box key={session.sessionId || `${session.projectPath}:${i}`} paddingX={1}>
8686
<Text
8787
color={isSelected ? INK_COLORS.text : INK_COLORS.textDim}
8888
backgroundColor={isSelected ? INK_COLORS.highlight : undefined}

0 commit comments

Comments
 (0)