projects.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. /**
  2. * Project / session metadata layer (PilotDeck-only).
  3. *
  4. * Replaces the legacy four-provider scanner that used to read
  5. * ~/.gemini/projects/. After the PilotDeck-only migration:
  6. *
  7. * - `getProjects()` lists projects via `gateway.listProjects()`.
  8. * - `getSessions()` lists session transcripts via
  9. * `gateway.listSessions()` (PilotDeck transcripts under
  10. * ~/.pilotdeck/projects/<id>/chats/<sessionKey>.jsonl).
  11. * - All sessions are returned in the single `sessions` array.
  12. *
  13. * Exports preserved for external callers under ui/server/:
  14. *
  15. * getProjects, getProjectCronJobsOverview, getSessions,
  16. * renameProject, deleteSession, deleteProject, addProjectManually,
  17. * extractProjectDirectory, clearProjectDirectoryCache,
  18. * searchConversations
  19. */
  20. import { promises as fs } from 'node:fs';
  21. import path from 'node:path';
  22. import os from 'node:os';
  23. import {
  24. getPilotDeckGateway,
  25. } from './pilotdeck-bridge.js';
  26. import { mapLegacySessionPresentation } from '../../src/web/server/legacySessionPresentation.js';
  27. import {
  28. resolvePilotHome,
  29. createProjectId,
  30. createCollisionResistantProjectId,
  31. sanitizeSessionIdForPath,
  32. } from './utils/pilotPaths.js';
  33. import { mapCronRunOutcome } from '../../src/cron/protocol/types.js';
  34. import sessionManager from './sessionManager.js';
  35. import { applyCustomSessionNames } from './database/db.js';
  36. // Optional taskmaster detection. Read once per project; lightweight.
  37. async function detectTaskMaster(projectPath) {
  38. try {
  39. const taskMasterDir = path.join(projectPath, '.taskmaster');
  40. const stat = await fs.stat(taskMasterDir);
  41. if (!stat.isDirectory()) {
  42. return { hasTaskmaster: false };
  43. }
  44. let tasksJson = false;
  45. try {
  46. await fs.access(path.join(taskMasterDir, 'tasks/tasks.json'));
  47. tasksJson = true;
  48. } catch {
  49. tasksJson = false;
  50. }
  51. return { hasTaskmaster: true, hasTasksJson: tasksJson };
  52. } catch {
  53. return { hasTaskmaster: false };
  54. }
  55. }
  56. const directoryCache = new Map();
  57. function rememberProjectDirectory(name, fullPath) {
  58. if (!name || !fullPath) return;
  59. directoryCache.set(name, fullPath);
  60. }
  61. function clearProjectDirectoryCache() {
  62. directoryCache.clear();
  63. }
  64. function projectDisplayName(fullPath) {
  65. return path.basename(fullPath) || fullPath;
  66. }
  67. /**
  68. * Map a PilotDeck `WebSessionInfo` onto the legacy `ProjectSession`
  69. * shape the React frontend expects.
  70. */
  71. function toLegacySession(session, projectName) {
  72. const presentation = mapLegacySessionPresentation(session);
  73. return {
  74. id: session.sessionId,
  75. title: presentation.title,
  76. summary: presentation.summary,
  77. name: presentation.name,
  78. createdAt: session.createdAt
  79. ? new Date(session.createdAt).toISOString()
  80. : new Date(session.lastModified || Date.now()).toISOString(),
  81. created_at: session.createdAt
  82. ? new Date(session.createdAt).toISOString()
  83. : new Date(session.lastModified || Date.now()).toISOString(),
  84. updated_at: session.lastModified
  85. ? new Date(session.lastModified).toISOString()
  86. : null,
  87. lastActivity: session.lastModified
  88. ? new Date(session.lastModified).toISOString()
  89. : null,
  90. messageCount: 0,
  91. cwd: session.cwd,
  92. customTitle: session.customTitle,
  93. aiTitle: session.aiTitle,
  94. firstPrompt: session.firstPrompt,
  95. tag: presentation.tag,
  96. __projectName: projectName,
  97. };
  98. }
  99. async function readMarkedProjectPaths() {
  100. // Scan ~/.pilotdeck/projects/<id>/.cwd to recover real workspace paths
  101. // for projects whose encoded id is ambiguous (see addProjectManually).
  102. // Returns a Map<id, absoluteCwd>; missing/unreadable markers are skipped.
  103. const pilotHome = resolvePilotHome(process.env);
  104. const projectsDir = path.join(pilotHome, 'projects');
  105. const result = new Map();
  106. let entries = [];
  107. try {
  108. entries = await fs.readdir(projectsDir, { withFileTypes: true });
  109. } catch {
  110. return result;
  111. }
  112. for (const entry of entries) {
  113. if (!entry.isDirectory()) continue;
  114. const cwdFile = path.join(projectsDir, entry.name, '.cwd');
  115. try {
  116. const raw = await fs.readFile(cwdFile, 'utf8');
  117. const cwd = raw.trim();
  118. if (cwd) result.set(entry.name, cwd);
  119. } catch {
  120. // No marker — listProjects can still surface this project via
  121. // its heuristic decoder when the path is unambiguous.
  122. }
  123. }
  124. return result;
  125. }
  126. async function getProjects(progressCallback = null) {
  127. const gateway = await getPilotDeckGateway();
  128. const { projects: webProjects } = await gateway.listProjects();
  129. const markedProjects = await readMarkedProjectPaths();
  130. const markedProjectIdsByPath = new Map(
  131. [...markedProjects.entries()].map(([id, cwd]) => [path.resolve(cwd), id]),
  132. );
  133. // Dedupe by `createProjectId(fullPath)` rather than raw path string.
  134. // The gateway's heuristic decoder for project ids (which collapses
  135. // `-` back into `/`) may produce a path that differs from the
  136. // verbatim path stored in `.cwd`, yet both encode to the same id —
  137. // and the SidebarV2 keys rows by that id. A raw-path Set would let
  138. // both rows through and produce a visible duplicate that share an
  139. // expand-state.
  140. //
  141. // Strategy: build a Map<projectId, entry> from the gateway list,
  142. // then for each `.cwd` marker either backfill a missing project or
  143. // override the existing entry's path with the marker (the marker is
  144. // the user-typed verbatim path, so it wins over the heuristic
  145. // decode). Session counts from the gateway are preserved.
  146. const byId = new Map();
  147. for (const project of webProjects) {
  148. const fullPath = project.fullPath || project.projectKey;
  149. if (!fullPath) continue;
  150. const id = markedProjectIdsByPath.get(path.resolve(fullPath)) || createProjectId(fullPath);
  151. if (!byId.has(id)) {
  152. byId.set(id, { ...project, __projectId: id });
  153. }
  154. }
  155. for (const [id, markedCwd] of markedProjects) {
  156. const existing = byId.get(id);
  157. if (existing) {
  158. existing.fullPath = markedCwd;
  159. existing.projectKey = markedCwd;
  160. existing.__projectId = id;
  161. } else {
  162. byId.set(id, {
  163. __projectId: id,
  164. fullPath: markedCwd,
  165. projectKey: markedCwd,
  166. sessionCount: 0,
  167. });
  168. }
  169. }
  170. const dedupedProjects = [...byId.values()];
  171. const total = dedupedProjects.length;
  172. const result = [];
  173. for (let index = 0; index < dedupedProjects.length; index += 1) {
  174. const project = dedupedProjects[index];
  175. const fullPath = project.fullPath || project.projectKey;
  176. const name = project.__projectId || createProjectId(fullPath);
  177. rememberProjectDirectory(name, fullPath);
  178. if (progressCallback) {
  179. progressCallback({
  180. phase: 'loading',
  181. processed: index,
  182. total,
  183. current: name,
  184. });
  185. }
  186. const sessionsResult = await gateway
  187. .listSessions({ projectKey: fullPath, limit: 5 })
  188. .catch(() => ({ sessions: [] }));
  189. const sessions = (sessionsResult.sessions || []).map((session) =>
  190. toLegacySession(session, name),
  191. );
  192. applyCustomSessionNames(sessions, 'claude');
  193. const taskmaster = await detectTaskMaster(fullPath).catch(() => ({
  194. hasTaskmaster: false,
  195. }));
  196. result.push({
  197. name,
  198. displayName: projectDisplayName(fullPath),
  199. fullPath,
  200. path: fullPath,
  201. lastActivity: project.lastActivity,
  202. sessions,
  203. sessionMeta: {
  204. total: project.sessionCount ?? sessions.length,
  205. hasMore: (project.sessionCount ?? sessions.length) > sessions.length,
  206. },
  207. taskmaster,
  208. alwaysOn: { enabled: false },
  209. });
  210. }
  211. if (progressCallback) {
  212. progressCallback({ phase: 'done', processed: total, total });
  213. }
  214. // Virtual "general" workspace — a non-project chat space rooted at
  215. // ~/.pilotdeck. SidebarV2 looks for a project whose `name` or
  216. // `displayName` equals 'general' to populate the dedicated "General"
  217. // toggle section. PilotDeck's gateway.listProjects() only returns
  218. // real project directories, so we synthesize one here. New chats
  219. // started from the General section use this cwd; sessions are
  220. // sourced from the same backend as any other project.
  221. const generalHome = resolvePilotHome(process.env);
  222. let generalSessions = [];
  223. let generalTotal = 0;
  224. let generalLastActivity;
  225. try {
  226. const generalGateway = await getPilotDeckGateway();
  227. // Pair the first page query with describeProject so the General
  228. // workspace gets the real session count instead of the page size.
  229. // Without this, sessionMeta.hasMore was hardcoded `false` and the
  230. // sidebar would silently truncate to the first 5 sessions even
  231. // when dozens existed under ~/.pilotdeck/projects/<encoded>/chats/.
  232. const [generalSessionsResult, generalSummary] = await Promise.all([
  233. generalGateway
  234. .listSessions({ projectKey: generalHome, limit: 5 })
  235. .catch(() => ({ sessions: [] })),
  236. generalGateway
  237. .describeProject({ projectKey: generalHome })
  238. .catch(() => null),
  239. ]);
  240. generalSessions = (generalSessionsResult.sessions || []).map((session) =>
  241. toLegacySession(session, 'general'),
  242. );
  243. applyCustomSessionNames(generalSessions, 'claude');
  244. generalTotal = typeof generalSummary?.sessionCount === 'number'
  245. ? generalSummary.sessionCount
  246. : generalSessions.length;
  247. generalLastActivity = generalSummary?.lastActivity;
  248. } catch {
  249. generalSessions = [];
  250. generalTotal = 0;
  251. generalLastActivity = undefined;
  252. }
  253. rememberProjectDirectory('general', generalHome);
  254. result.unshift({
  255. name: 'general',
  256. displayName: 'general',
  257. fullPath: generalHome,
  258. path: generalHome,
  259. lastActivity: generalLastActivity,
  260. sessions: generalSessions,
  261. sessionMeta: {
  262. total: generalTotal,
  263. hasMore: generalTotal > generalSessions.length,
  264. },
  265. taskmaster: { hasTaskmaster: false },
  266. alwaysOn: { enabled: false },
  267. });
  268. return result;
  269. }
  270. async function getSessions(projectName, limit = 5, offset = 0) {
  271. const gateway = await getPilotDeckGateway();
  272. const projectPath = await extractProjectDirectory(projectName);
  273. const cursor = offset > 0 ? String(offset) : undefined;
  274. // Fan-out the page query and the project summary (for the authoritative
  275. // total session count) in parallel. Without summary.sessionCount we'd
  276. // have to estimate `total` as `offset + page.length + hasMoreBump`,
  277. // which the UI then uses to compute `remaining = total - allLoaded`.
  278. // That estimate drifts every page and ends up showing a stale
  279. // "Show more (N)" that never reaches the real count — which presents
  280. // to the user as a button that "doesn't react" once they've already
  281. // pulled in everything that exists.
  282. const [listResult, summary] = await Promise.all([
  283. gateway
  284. .listSessions({ projectKey: projectPath, limit, cursor })
  285. .catch(() => ({ sessions: [] })),
  286. gateway
  287. .describeProject({ projectKey: projectPath })
  288. .catch(() => null),
  289. ]);
  290. const sessions = (listResult.sessions || []).map((session) =>
  291. toLegacySession(session, projectName),
  292. );
  293. const hasMore = Boolean(listResult.nextCursor);
  294. const fallbackTotal = offset + sessions.length + (hasMore ? 1 : 0);
  295. const total = typeof summary?.sessionCount === 'number'
  296. ? summary.sessionCount
  297. : fallbackTotal;
  298. return {
  299. sessions,
  300. total,
  301. hasMore,
  302. offset,
  303. limit,
  304. };
  305. }
  306. /**
  307. * Resolve a `projectName` (encoded form like `-Users-miwi-PilotDeck`,
  308. * a basename, or an already-absolute path) to the absolute project root.
  309. * Falls back to consulting the directory cache populated by
  310. * `getProjects()` so worktree-aware paths resolve correctly.
  311. */
  312. async function extractProjectDirectory(projectName) {
  313. if (!projectName) {
  314. return resolvePilotHome(process.env);
  315. }
  316. if (path.isAbsolute(projectName)) {
  317. rememberProjectDirectory(projectName, projectName);
  318. return projectName;
  319. }
  320. const cached = directoryCache.get(projectName);
  321. if (cached) {
  322. return cached;
  323. }
  324. const markedProjects = await readMarkedProjectPaths();
  325. const marked = markedProjects.get(projectName);
  326. if (marked) {
  327. rememberProjectDirectory(projectName, marked);
  328. return marked;
  329. }
  330. if (projectName.startsWith('-')) {
  331. // Legacy dash-encoding heuristic: `-Users-foo-foo` → `/Users/foo/foo`.
  332. const decoded = '/' + projectName.replace(/^-+/, '').replace(/-/g, '/');
  333. rememberProjectDirectory(projectName, decoded);
  334. return decoded;
  335. }
  336. return resolvePilotHome(process.env);
  337. }
  338. async function addProjectManually(projectPath, _displayName = null) {
  339. if (!projectPath) {
  340. throw new Error('projectPath is required');
  341. }
  342. const absolute = path.resolve(projectPath);
  343. const pilotHome = resolvePilotHome(process.env);
  344. const name = await allocateProjectIdForPath(absolute, pilotHome);
  345. rememberProjectDirectory(name, absolute);
  346. // Materialize a PilotDeck project directory and drop a `.cwd` marker
  347. // recording the real absolute path. We need the marker because
  348. // createProjectId() encodes both '/' and literal '-' to '-', so the
  349. // PilotDeck's listWebProjects() heuristically tries each `-` as a
  350. // path separator and drops the project when no decode matches an
  351. // existing directory — which would silently lose workspaces whose
  352. // real path contains a dash. getProjects() reads `.cwd` to backfill
  353. // any project listProjects() couldn't recover.
  354. const projectDir = path.join(pilotHome, 'projects', name);
  355. try {
  356. await fs.mkdir(projectDir, { recursive: true });
  357. await fs.writeFile(path.join(projectDir, '.cwd'), absolute, 'utf8');
  358. } catch (error) {
  359. console.warn(
  360. `[projects] failed to materialize PilotDeck project dir for ${name}:`,
  361. error?.message || error,
  362. );
  363. }
  364. return {
  365. name,
  366. displayName: projectDisplayName(absolute),
  367. fullPath: absolute,
  368. path: absolute,
  369. };
  370. }
  371. async function allocateProjectIdForPath(absolutePath, pilotHome) {
  372. const legacyId = createProjectId(absolutePath);
  373. const legacyDir = path.join(pilotHome, 'projects', legacyId);
  374. try {
  375. await fs.access(legacyDir);
  376. } catch (error) {
  377. if (error?.code === 'ENOENT') {
  378. return legacyId;
  379. }
  380. throw error;
  381. }
  382. const markerPath = path.join(legacyDir, '.cwd');
  383. try {
  384. const marker = (await fs.readFile(markerPath, 'utf8')).trim();
  385. if (marker && path.resolve(marker) === absolutePath) {
  386. return legacyId;
  387. }
  388. } catch (error) {
  389. if (error?.code !== 'ENOENT') {
  390. throw error;
  391. }
  392. }
  393. return createCollisionResistantProjectId(absolutePath);
  394. }
  395. async function renameProject(_projectName, _displayName) {
  396. // PilotDeck does not yet expose a rename API. Display names are derived
  397. // from the project's basename today, so this is a no-op.
  398. return { success: true };
  399. }
  400. async function deleteSession(projectName, sessionId, _options = {}) {
  401. const fullPath = await extractProjectDirectory(projectName);
  402. const pilotHome = resolvePilotHome(process.env);
  403. const projectId = await resolveProjectIdForPathOrName(projectName, fullPath);
  404. // Try the sanitized filename first (current storage layout), then the
  405. // raw form (legacy files written before the sanitize fix).
  406. const safeId = sanitizeSessionIdForPath(sessionId);
  407. const filenames = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  408. let removed = false;
  409. for (const name of filenames) {
  410. const transcript = path.join(
  411. pilotHome,
  412. 'projects',
  413. projectId,
  414. 'chats',
  415. `${name}.jsonl`,
  416. );
  417. try {
  418. await fs.unlink(transcript);
  419. removed = true;
  420. } catch (error) {
  421. if (error?.code !== 'ENOENT') {
  422. throw error;
  423. }
  424. }
  425. }
  426. return removed;
  427. }
  428. async function deleteProject(projectName, force = false) {
  429. const fullPath = await extractProjectDirectory(projectName);
  430. const pilotHome = resolvePilotHome(process.env);
  431. const projectId = await resolveProjectIdForPathOrName(projectName, fullPath);
  432. const projectDir = path.join(pilotHome, 'projects', projectId);
  433. try {
  434. await fs.rm(projectDir, { recursive: true, force });
  435. directoryCache.delete(projectName);
  436. return true;
  437. } catch (error) {
  438. if (error?.code === 'ENOENT') {
  439. return false;
  440. }
  441. throw error;
  442. }
  443. }
  444. async function resolveProjectIdForPathOrName(projectName, fullPath) {
  445. const markedProjects = await readMarkedProjectPaths();
  446. if (projectName && !path.isAbsolute(projectName) && markedProjects.has(projectName)) {
  447. return projectName;
  448. }
  449. const resolved = path.resolve(fullPath);
  450. for (const [id, cwd] of markedProjects) {
  451. if (path.resolve(cwd) === resolved) {
  452. return id;
  453. }
  454. }
  455. return createProjectId(fullPath);
  456. }
  457. async function getProjectCronJobsOverview(_projectName) {
  458. try {
  459. const gateway = await getPilotDeckGateway();
  460. const result = await gateway.cronList({ includeHistory: true, limit: 50 });
  461. const runsByTaskId = new Map();
  462. if (Array.isArray(result.recentRuns)) {
  463. for (const run of result.recentRuns) {
  464. if (!run.taskId) continue;
  465. const existing = runsByTaskId.get(run.taskId);
  466. if (!existing || run.startedAt > existing.startedAt) {
  467. runsByTaskId.set(run.taskId, run);
  468. }
  469. }
  470. }
  471. const jobs = (result.tasks || []).map((task) => {
  472. const latestRun = runsByTaskId.get(task.taskId) || null;
  473. const isCron = task.schedule?.type === 'cron';
  474. return {
  475. id: task.taskId,
  476. projectKey: task.projectKey || null,
  477. cron: isCron ? task.schedule.expression : '',
  478. prompt: task.message || '',
  479. createdAt: task.createdAt,
  480. recurring: isCron,
  481. permanent: isCron,
  482. manualOnly: false,
  483. status: task.status === 'running' ? 'running' : 'scheduled',
  484. lastFiredAt: latestRun?.startedAt ? new Date(latestRun.startedAt).getTime() : undefined,
  485. latestRun: latestRun ? {
  486. status: mapCronRunOutcome(latestRun.outcome, latestRun.finishedAt),
  487. runId: latestRun.runId,
  488. startedAt: latestRun.startedAt,
  489. taskId: latestRun.taskId,
  490. sessionId: latestRun.sessionKey,
  491. } : null,
  492. };
  493. });
  494. return { jobs };
  495. } catch (error) {
  496. console.warn('[projects] cronList via gateway failed, returning empty:', error?.message);
  497. return { jobs: [] };
  498. }
  499. }
  500. async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
  501. const needle = (query || '').trim().toLowerCase();
  502. if (!needle) {
  503. return { totalMatches: 0 };
  504. }
  505. const projects = await getProjects();
  506. let totalMatches = 0;
  507. for (let index = 0; index < projects.length; index += 1) {
  508. if (signal?.aborted) break;
  509. const project = projects[index];
  510. const matches = (project.sessions || []).filter((session) => {
  511. const haystack = [
  512. session.title,
  513. session.summary,
  514. session.customTitle,
  515. session.aiTitle,
  516. session.firstPrompt,
  517. ]
  518. .filter(Boolean)
  519. .join(' ')
  520. .toLowerCase();
  521. return haystack.includes(needle);
  522. });
  523. if (matches.length > 0) {
  524. const projectResult = {
  525. project: { name: project.name, fullPath: project.fullPath },
  526. matches,
  527. };
  528. totalMatches += matches.length;
  529. if (onProjectResult) {
  530. await Promise.resolve(
  531. onProjectResult({
  532. projectResult,
  533. totalMatches,
  534. scannedProjects: index + 1,
  535. totalProjects: projects.length,
  536. }),
  537. ).catch(() => undefined);
  538. }
  539. if (totalMatches >= limit) break;
  540. }
  541. }
  542. return { totalMatches };
  543. }
  544. export {
  545. getProjects,
  546. getProjectCronJobsOverview,
  547. getSessions,
  548. renameProject,
  549. deleteSession,
  550. deleteProject,
  551. addProjectManually,
  552. extractProjectDirectory,
  553. clearProjectDirectoryCache,
  554. searchConversations,
  555. };