* Project / session metadata layer (PilotDeck-only).
*
* Replaces the legacy four-provider scanner that used to read
* ~/.gemini/projects/. After the PilotDeck-only migration:
*
* - `getProjects()` lists projects via `gateway.listProjects()`.
* - `getSessions()` lists session transcripts via
* `gateway.listSessions()` (PilotDeck transcripts under
* ~/.pilotdeck/projects/<id>/chats/<sessionKey>.jsonl).
* - All sessions are returned in the single `sessions` array.
*
* Exports preserved for external callers under ui/server/:
*
* getProjects, getProjectCronJobsOverview, getSessions,
* renameProject, deleteSession, deleteProject, addProjectManually,
* extractProjectDirectory, clearProjectDirectoryCache,
* searchConversations
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
getPilotDeckGateway,
} from './pilotdeck-bridge.js';
import { mapLegacySessionPresentation } from '../../src/web/server/legacySessionPresentation.js';
import {
resolvePilotHome,
createProjectId,
createCollisionResistantProjectId,
sanitizeSessionIdForPath,
} from './utils/pilotPaths.js';
import { mapCronRunOutcome } from '../../src/cron/protocol/types.js';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
async function detectTaskMaster(projectPath) {
try {
const taskMasterDir = path.join(projectPath, '.taskmaster');
const stat = await fs.stat(taskMasterDir);
if (!stat.isDirectory()) {
return { hasTaskmaster: false };
}
let tasksJson = false;
try {
await fs.access(path.join(taskMasterDir, 'tasks/tasks.json'));
tasksJson = true;
} catch {
tasksJson = false;
}
return { hasTaskmaster: true, hasTasksJson: tasksJson };
} catch {
return { hasTaskmaster: false };
}
}
const directoryCache = new Map();
function rememberProjectDirectory(name, fullPath) {
if (!name || !fullPath) return;
directoryCache.set(name, fullPath);
}
function clearProjectDirectoryCache() {
directoryCache.clear();
}
function projectDisplayName(fullPath) {
return path.basename(fullPath) || fullPath;
}
* Map a PilotDeck `WebSessionInfo` onto the legacy `ProjectSession`
* shape the React frontend expects.
*/
function toLegacySession(session, projectName) {
const presentation = mapLegacySessionPresentation(session);
return {
id: session.sessionId,
title: presentation.title,
summary: presentation.summary,
name: presentation.name,
createdAt: session.createdAt
? new Date(session.createdAt).toISOString()
: new Date(session.lastModified || Date.now()).toISOString(),
created_at: session.createdAt
? new Date(session.createdAt).toISOString()
: new Date(session.lastModified || Date.now()).toISOString(),
updated_at: session.lastModified
? new Date(session.lastModified).toISOString()
: null,
lastActivity: session.lastModified
? new Date(session.lastModified).toISOString()
: null,
messageCount: 0,
cwd: session.cwd,
customTitle: session.customTitle,
aiTitle: session.aiTitle,
firstPrompt: session.firstPrompt,
tag: presentation.tag,
__projectName: projectName,
};
}
async function readMarkedProjectPaths() {
const pilotHome = resolvePilotHome(process.env);
const projectsDir = path.join(pilotHome, 'projects');
const result = new Map();
let entries = [];
try {
entries = await fs.readdir(projectsDir, { withFileTypes: true });
} catch {
return result;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const cwdFile = path.join(projectsDir, entry.name, '.cwd');
try {
const raw = await fs.readFile(cwdFile, 'utf8');
const cwd = raw.trim();
if (cwd) result.set(entry.name, cwd);
} catch {
}
}
return result;
}
async function getProjects(progressCallback = null) {
const gateway = await getPilotDeckGateway();
const { projects: webProjects } = await gateway.listProjects();
const markedProjects = await readMarkedProjectPaths();
const markedProjectIdsByPath = new Map(
[...markedProjects.entries()].map(([id, cwd]) => [path.resolve(cwd), id]),
);
const byId = new Map();
for (const project of webProjects) {
const fullPath = project.fullPath || project.projectKey;
if (!fullPath) continue;
const id = markedProjectIdsByPath.get(path.resolve(fullPath)) || createProjectId(fullPath);
if (!byId.has(id)) {
byId.set(id, { ...project, __projectId: id });
}
}
for (const [id, markedCwd] of markedProjects) {
const existing = byId.get(id);
if (existing) {
existing.fullPath = markedCwd;
existing.projectKey = markedCwd;
existing.__projectId = id;
} else {
byId.set(id, {
__projectId: id,
fullPath: markedCwd,
projectKey: markedCwd,
sessionCount: 0,
});
}
}
const dedupedProjects = [...byId.values()];
const total = dedupedProjects.length;
const result = [];
for (let index = 0; index < dedupedProjects.length; index += 1) {
const project = dedupedProjects[index];
const fullPath = project.fullPath || project.projectKey;
const name = project.__projectId || createProjectId(fullPath);
rememberProjectDirectory(name, fullPath);
if (progressCallback) {
progressCallback({
phase: 'loading',
processed: index,
total,
current: name,
});
}
const sessionsResult = await gateway
.listSessions({ projectKey: fullPath, limit: 5 })
.catch(() => ({ sessions: [] }));
const sessions = (sessionsResult.sessions || []).map((session) =>
toLegacySession(session, name),
);
applyCustomSessionNames(sessions, 'claude');
const taskmaster = await detectTaskMaster(fullPath).catch(() => ({
hasTaskmaster: false,
}));
result.push({
name,
displayName: projectDisplayName(fullPath),
fullPath,
path: fullPath,
sessions,
sessionMeta: {
total: project.sessionCount ?? sessions.length,
hasMore: (project.sessionCount ?? sessions.length) > sessions.length,
},
taskmaster,
alwaysOn: { enabled: false },
});
}
if (progressCallback) {
progressCallback({ phase: 'done', processed: total, total });
}
const generalHome = resolvePilotHome(process.env);
let generalSessions = [];
let generalTotal = 0;
try {
const generalGateway = await getPilotDeckGateway();
const [generalSessionsResult, generalSummary] = await Promise.all([
generalGateway
.listSessions({ projectKey: generalHome, limit: 5 })
.catch(() => ({ sessions: [] })),
generalGateway
.describeProject({ projectKey: generalHome })
.catch(() => null),
]);
generalSessions = (generalSessionsResult.sessions || []).map((session) =>
toLegacySession(session, 'general'),
);
applyCustomSessionNames(generalSessions, 'claude');
generalTotal = typeof generalSummary?.sessionCount === 'number'
? generalSummary.sessionCount
: generalSessions.length;
} catch {
generalSessions = [];
generalTotal = 0;
}
rememberProjectDirectory('general', generalHome);
result.unshift({
name: 'general',
displayName: 'general',
fullPath: generalHome,
path: generalHome,
sessions: generalSessions,
sessionMeta: {
total: generalTotal,
hasMore: generalTotal > generalSessions.length,
},
taskmaster: { hasTaskmaster: false },
alwaysOn: { enabled: false },
});
return result;
}
async function getSessions(projectName, limit = 5, offset = 0) {
const gateway = await getPilotDeckGateway();
const projectPath = await extractProjectDirectory(projectName);
const cursor = offset > 0 ? String(offset) : undefined;
const [listResult, summary] = await Promise.all([
gateway
.listSessions({ projectKey: projectPath, limit, cursor })
.catch(() => ({ sessions: [] })),
gateway
.describeProject({ projectKey: projectPath })
.catch(() => null),
]);
const sessions = (listResult.sessions || []).map((session) =>
toLegacySession(session, projectName),
);
const hasMore = Boolean(listResult.nextCursor);
const fallbackTotal = offset + sessions.length + (hasMore ? 1 : 0);
const total = typeof summary?.sessionCount === 'number'
? summary.sessionCount
: fallbackTotal;
return {
sessions,
total,
hasMore,
offset,
limit,
};
}
* Resolve a `projectName` (encoded form like `-Users-miwi-PilotDeck`,
* a basename, or an already-absolute path) to the absolute project root.
* Falls back to consulting the directory cache populated by
* `getProjects()` so worktree-aware paths resolve correctly.
*/
async function extractProjectDirectory(projectName) {
if (!projectName) {
return resolvePilotHome(process.env);
}
if (path.isAbsolute(projectName)) {
rememberProjectDirectory(projectName, projectName);
return projectName;
}
const cached = directoryCache.get(projectName);
if (cached) {
return cached;
}
const markedProjects = await readMarkedProjectPaths();
const marked = markedProjects.get(projectName);
if (marked) {
rememberProjectDirectory(projectName, marked);
return marked;
}
if (projectName.startsWith('-')) {
const decoded = '/' + projectName.replace(/^-+/, '').replace(/-/g, '/');
rememberProjectDirectory(projectName, decoded);
return decoded;
}
return resolvePilotHome(process.env);
}
async function addProjectManually(projectPath, _displayName = null) {
if (!projectPath) {
throw new Error('projectPath is required');
}
const absolute = path.resolve(projectPath);
const pilotHome = resolvePilotHome(process.env);
const name = await allocateProjectIdForPath(absolute, pilotHome);
rememberProjectDirectory(name, absolute);
const projectDir = path.join(pilotHome, 'projects', name);
try {
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(projectDir, '.cwd'), absolute, 'utf8');
} catch (error) {
console.warn(
`[projects] failed to materialize PilotDeck project dir for ${name}:`,
error?.message || error,
);
}
return {
name,
displayName: projectDisplayName(absolute),
fullPath: absolute,
path: absolute,
};
}
async function allocateProjectIdForPath(absolutePath, pilotHome) {
const legacyId = createProjectId(absolutePath);
const legacyDir = path.join(pilotHome, 'projects', legacyId);
try {
await fs.access(legacyDir);
} catch (error) {
if (error?.code === 'ENOENT') {
return legacyId;
}
throw error;
}
const markerPath = path.join(legacyDir, '.cwd');
try {
const marker = (await fs.readFile(markerPath, 'utf8')).trim();
if (marker && path.resolve(marker) === absolutePath) {
return legacyId;
}
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
return createCollisionResistantProjectId(absolutePath);
}
async function renameProject(_projectName, _displayName) {
return { success: true };
}
async function deleteSession(projectName, sessionId, _options = {}) {
const fullPath = await extractProjectDirectory(projectName);
const pilotHome = resolvePilotHome(process.env);
const projectId = await resolveProjectIdForPathOrName(projectName, fullPath);
const safeId = sanitizeSessionIdForPath(sessionId);
const filenames = safeId === sessionId ? [sessionId] : [safeId, sessionId];
let removed = false;
for (const name of filenames) {
const transcript = path.join(
pilotHome,
'projects',
projectId,
'chats',
`${name}.jsonl`,
);
try {
await fs.unlink(transcript);
removed = true;
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
}
return removed;
}
async function deleteProject(projectName, force = false) {
const fullPath = await extractProjectDirectory(projectName);
const pilotHome = resolvePilotHome(process.env);
const projectId = await resolveProjectIdForPathOrName(projectName, fullPath);
const projectDir = path.join(pilotHome, 'projects', projectId);
try {
await fs.rm(projectDir, { recursive: true, force });
directoryCache.delete(projectName);
return true;
} catch (error) {
if (error?.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function resolveProjectIdForPathOrName(projectName, fullPath) {
const markedProjects = await readMarkedProjectPaths();
if (projectName && !path.isAbsolute(projectName) && markedProjects.has(projectName)) {
return projectName;
}
const resolved = path.resolve(fullPath);
for (const [id, cwd] of markedProjects) {
if (path.resolve(cwd) === resolved) {
return id;
}
}
return createProjectId(fullPath);
}
async function getProjectCronJobsOverview(_projectName) {
try {
const gateway = await getPilotDeckGateway();
const result = await gateway.cronList({ includeHistory: true, limit: 50 });
const runsByTaskId = new Map();
if (Array.isArray(result.recentRuns)) {
for (const run of result.recentRuns) {
if (!run.taskId) continue;
const existing = runsByTaskId.get(run.taskId);
if (!existing || run.startedAt > existing.startedAt) {
runsByTaskId.set(run.taskId, run);
}
}
}
const jobs = (result.tasks || []).map((task) => {
const latestRun = runsByTaskId.get(task.taskId) || null;
const isCron = task.schedule?.type === 'cron';
return {
id: task.taskId,
projectKey: task.projectKey || null,
cron: isCron ? task.schedule.expression : '',
prompt: task.message || '',
createdAt: task.createdAt,
recurring: isCron,
permanent: isCron,
manualOnly: false,
status: task.status === 'running' ? 'running' : 'scheduled',
lastFiredAt: latestRun?.startedAt ? new Date(latestRun.startedAt).getTime() : undefined,
latestRun: latestRun ? {
status: mapCronRunOutcome(latestRun.outcome, latestRun.finishedAt),
runId: latestRun.runId,
startedAt: latestRun.startedAt,
taskId: latestRun.taskId,
sessionId: latestRun.sessionKey,
} : null,
};
});
return { jobs };
} catch (error) {
console.warn('[projects] cronList via gateway failed, returning empty:', error?.message);
return { jobs: [] };
}
}
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
const needle = (query || '').trim().toLowerCase();
if (!needle) {
return { totalMatches: 0 };
}
const projects = await getProjects();
let totalMatches = 0;
for (let index = 0; index < projects.length; index += 1) {
if (signal?.aborted) break;
const project = projects[index];
const matches = (project.sessions || []).filter((session) => {
const haystack = [
session.title,
session.summary,
session.customTitle,
session.aiTitle,
session.firstPrompt,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(needle);
});
if (matches.length > 0) {
const projectResult = {
project: { name: project.name, fullPath: project.fullPath },
matches,
};
totalMatches += matches.length;
if (onProjectResult) {
await Promise.resolve(
onProjectResult({
projectResult,
totalMatches,
scannedProjects: index + 1,
totalProjects: projects.length,
}),
).catch(() => undefined);
}
if (totalMatches >= limit) break;
}
}
return { totalMatches };
}
export {
getProjects,
getProjectCronJobsOverview,
getSessions,
renameProject,
deleteSession,
deleteProject,
addProjectManually,
extractProjectDirectory,
clearProjectDirectoryCache,
searchConversations,
};