@mrpatronz/nexusflow 2.21.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/gui/assets/AgentsPage-CwgqlrPd.js +3 -0
  2. package/dist/gui/assets/DashboardPage-BdBo1iDm.js +1 -0
  3. package/dist/gui/assets/{DocumentPreview-BlUlSRK0.js → DocumentPreview-BF79hZoZ.js} +2 -2
  4. package/dist/gui/assets/{GettingStartedPage-C-2t-udq.js → GettingStartedPage-BhPWS6qy.js} +1 -1
  5. package/dist/gui/assets/{MonacoDiffAdapter-C1gYxNl1.js → MonacoDiffAdapter-DgGLd1S5.js} +1 -1
  6. package/dist/gui/assets/{ProjectsPage-Bll4qUCc.js → ProjectsPage-DfSB-Qul.js} +1 -1
  7. package/dist/gui/assets/{ScaffoldRepoInline-BYGKL4qv.js → ScaffoldRepoInline-y0YoOFK6.js} +1 -1
  8. package/dist/gui/assets/SettingsPage-BHly-6Wc.js +1 -0
  9. package/dist/gui/assets/{SkillsPage-CLGLrRq3.js → SkillsPage-BA2-hZk7.js} +3 -3
  10. package/dist/gui/assets/StartWorkPage-BIhkPxp6.js +1 -0
  11. package/dist/gui/assets/{StrategiesPage-nNBMOxvu.js → StrategiesPage-DCPhSuM4.js} +2 -2
  12. package/dist/gui/assets/WorkroomsPage-BKSxMbg3.js +4 -0
  13. package/dist/gui/assets/WorkspacesPage-DfTNHupN.js +17 -0
  14. package/dist/gui/assets/{alert-dialog-DnwZtA87.js → alert-dialog-D0AKLuV3.js} +1 -1
  15. package/dist/gui/assets/{index-R0fvXNEi.js → index-CvEbFSmU.js} +35 -35
  16. package/dist/gui/assets/index-XMSrNppd.css +2 -0
  17. package/dist/gui/assets/{card-BMggHY3n.js → label-D5LXpdA9.js} +1 -1
  18. package/dist/gui/assets/queries-BSNhQxoS.js +3 -0
  19. package/dist/gui/assets/{tabs-CIkN5ISA.js → tabs-DRynuaQV.js} +1 -1
  20. package/dist/gui/index.html +3 -3
  21. package/dist/server.d.ts.map +1 -1
  22. package/dist/server.js +72 -37
  23. package/dist/server.js.map +1 -1
  24. package/dist/terminal/routes.d.ts.map +1 -1
  25. package/dist/terminal/routes.js +2 -1
  26. package/dist/terminal/routes.js.map +1 -1
  27. package/dist/terminal/targets.d.ts +2 -28
  28. package/dist/terminal/targets.d.ts.map +1 -1
  29. package/dist/terminal/targets.js +12 -27
  30. package/dist/terminal/targets.js.map +1 -1
  31. package/dist/types.d.ts +6 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/utils/cli-harnesses.d.ts +56 -0
  34. package/dist/utils/cli-harnesses.d.ts.map +1 -0
  35. package/dist/utils/cli-harnesses.js +20 -0
  36. package/dist/utils/cli-harnesses.js.map +1 -0
  37. package/dist/utils/pi-sessions.d.ts +5 -0
  38. package/dist/utils/pi-sessions.d.ts.map +1 -0
  39. package/dist/utils/pi-sessions.js +150 -0
  40. package/dist/utils/pi-sessions.js.map +1 -0
  41. package/dist/utils/session-finder.d.ts +10 -6
  42. package/dist/utils/session-finder.d.ts.map +1 -1
  43. package/dist/utils/session-finder.js +369 -340
  44. package/dist/utils/session-finder.js.map +1 -1
  45. package/dist/utils/session-readers.d.ts +21 -0
  46. package/dist/utils/session-readers.d.ts.map +1 -0
  47. package/dist/utils/session-readers.js +14 -0
  48. package/dist/utils/session-readers.js.map +1 -0
  49. package/dist/utils/terminal-launch.d.ts +1 -0
  50. package/dist/utils/terminal-launch.d.ts.map +1 -1
  51. package/dist/utils/terminal-launch.js +11 -32
  52. package/dist/utils/terminal-launch.js.map +1 -1
  53. package/package.json +1 -1
  54. package/dist/gui/assets/AgentsPage-Dhd1ZqwZ.js +0 -3
  55. package/dist/gui/assets/DashboardPage-DuG3Nx62.js +0 -1
  56. package/dist/gui/assets/SettingsPage-DP1pJNJm.js +0 -1
  57. package/dist/gui/assets/StartWorkPage-CcCPKOv8.js +0 -1
  58. package/dist/gui/assets/WorkroomsPage-CnH1E8mW.js +0 -4
  59. package/dist/gui/assets/WorkspacesPage-DblU6U2z.js +0 -17
  60. package/dist/gui/assets/index-BO8b3Dk2.css +0 -2
  61. package/dist/gui/assets/label-C610MHwq.js +0 -1
  62. package/dist/gui/assets/queries-F2H3SwGY.js +0 -3
@@ -5,6 +5,8 @@
5
5
  import * as fs from 'node:fs/promises';
6
6
  import * as path from 'node:path';
7
7
  import * as os from 'node:os';
8
+ import { MODULAR_SESSION_READERS, modularSessionReader } from './session-readers.js';
9
+ export { SESSION_SOURCES } from './cli-harnesses.js';
8
10
  export function extractRecordUsage(record) {
9
11
  if (!record || typeof record !== 'object')
10
12
  return {};
@@ -437,13 +439,14 @@ export async function canTransferClaudeSessionInWorkspace(workspacePath, repoPat
437
439
  }
438
440
  /**
439
441
  * Scans the local filesystem for conversation histories belonging to Claude, Antigravity,
440
- * Codex, and Copilot that relate to the specified workspace.
442
+ * Codex, Copilot, and Pi that relate to the specified workspace.
441
443
  *
442
444
  * @param workspacePath - Root directory of the active workspace.
443
445
  * @param repoPaths - Directories of sub-repositories included in the workspace.
446
+ * @param source - Optional history source to scan independently for progressive loading.
444
447
  * @returns A promise that resolves to an array of {@link AISession} objects sorted by update time descending.
445
448
  */
446
- export async function findSessions(workspacePath, repoPaths = []) {
449
+ export async function findSessions(workspacePath, repoPaths = [], source) {
447
450
  const sessions = [];
448
451
  const wsFolderName = path.basename(workspacePath);
449
452
  // Normalization helper for accurate path comparison
@@ -482,392 +485,410 @@ export async function findSessions(workspacePath, repoPaths = []) {
482
485
  return workspacePath;
483
486
  };
484
487
  // ─── 1. Scan Antigravity Sessions ──────────────────────────────────────
485
- const agDir = getAntigravityDir();
486
- const agHistoryPath = path.join(agDir, 'history.jsonl');
487
- try {
488
- const historyContent = await fs.readFile(agHistoryPath, 'utf-8');
489
- const lines = historyContent.split('\n').filter(Boolean);
490
- const conversationsMap = new Map();
491
- for (const line of lines) {
492
- try {
493
- const entry = JSON.parse(line);
494
- if (entry.conversationId && isPathMatch(entry.workspace)) {
495
- if (!conversationsMap.has(entry.conversationId)) {
496
- conversationsMap.set(entry.conversationId, {
497
- id: entry.conversationId,
498
- title: entry.display || 'Untitled Conversation',
499
- entries: [],
500
- });
488
+ if (!source || source === 'antigravity') {
489
+ const agDir = getAntigravityDir();
490
+ const agHistoryPath = path.join(agDir, 'history.jsonl');
491
+ try {
492
+ const historyContent = await fs.readFile(agHistoryPath, 'utf-8');
493
+ const lines = historyContent.split('\n').filter(Boolean);
494
+ const conversationsMap = new Map();
495
+ for (const line of lines) {
496
+ try {
497
+ const entry = JSON.parse(line);
498
+ if (entry.conversationId && isPathMatch(entry.workspace)) {
499
+ if (!conversationsMap.has(entry.conversationId)) {
500
+ conversationsMap.set(entry.conversationId, {
501
+ id: entry.conversationId,
502
+ title: entry.display || 'Untitled Conversation',
503
+ entries: [],
504
+ });
505
+ }
506
+ conversationsMap.get(entry.conversationId).entries.push(entry);
501
507
  }
502
- conversationsMap.get(entry.conversationId).entries.push(entry);
503
508
  }
509
+ catch { }
504
510
  }
505
- catch { }
506
- }
507
- for (const [convId, convData] of conversationsMap.entries()) {
508
- const transcriptPath = path.join(agDir, 'brain', convId, '.system_generated', 'logs', 'transcript.jsonl');
509
- let messageCount = 0;
510
- const times = sessionTimes();
511
- for (const entry of convData.entries)
512
- times.add(entry.timestamp);
513
- let title = convData.title;
514
- let sessionUsage;
515
- let latestQuota;
516
- try {
517
- const transcriptContent = await fs.readFile(transcriptPath, 'utf-8');
518
- const transcriptLines = transcriptContent.split('\n').filter(Boolean);
519
- let firstUserMessage = null;
520
- for (const line of transcriptLines) {
521
- try {
522
- const tObj = JSON.parse(line);
523
- times.add(tObj.timestamp);
524
- if (tObj.type === 'USER_INPUT' || tObj.type === 'PLANNER_RESPONSE') {
525
- messageCount++;
526
- if (tObj.type === 'USER_INPUT' && !firstUserMessage) {
527
- firstUserMessage = tObj.content;
511
+ for (const [convId, convData] of conversationsMap.entries()) {
512
+ const transcriptPath = path.join(agDir, 'brain', convId, '.system_generated', 'logs', 'transcript.jsonl');
513
+ let messageCount = 0;
514
+ const times = sessionTimes();
515
+ for (const entry of convData.entries)
516
+ times.add(entry.timestamp);
517
+ let title = convData.title;
518
+ let sessionUsage;
519
+ let latestQuota;
520
+ try {
521
+ const transcriptContent = await fs.readFile(transcriptPath, 'utf-8');
522
+ const transcriptLines = transcriptContent.split('\n').filter(Boolean);
523
+ let firstUserMessage = null;
524
+ for (const line of transcriptLines) {
525
+ try {
526
+ const tObj = JSON.parse(line);
527
+ times.add(tObj.timestamp);
528
+ if (tObj.type === 'USER_INPUT' || tObj.type === 'PLANNER_RESPONSE') {
529
+ messageCount++;
530
+ if (tObj.type === 'USER_INPUT' && !firstUserMessage) {
531
+ firstUserMessage = tObj.content;
532
+ }
528
533
  }
534
+ const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(tObj);
535
+ if (turnUsage)
536
+ sessionUsage = accumulateUsage(sessionUsage, turnUsage);
537
+ if (turnQuota)
538
+ latestQuota = turnQuota;
529
539
  }
530
- const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(tObj);
531
- if (turnUsage)
532
- sessionUsage = accumulateUsage(sessionUsage, turnUsage);
533
- if (turnQuota)
534
- latestQuota = turnQuota;
540
+ catch { }
541
+ }
542
+ if (firstUserMessage) {
543
+ const cleanMatch = firstUserMessage.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
544
+ title = cleanMatch ? cleanMatch[1].trim() : firstUserMessage.trim();
535
545
  }
536
- catch { }
537
546
  }
538
- if (firstUserMessage) {
539
- const cleanMatch = firstUserMessage.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
540
- title = cleanMatch ? cleanMatch[1].trim() : firstUserMessage.trim();
547
+ catch {
548
+ messageCount = convData.entries.length * 2;
541
549
  }
550
+ sessions.push({
551
+ id: convId,
552
+ assistant: 'antigravity',
553
+ title: sanitizeSessionTitle(title),
554
+ ...times.values(),
555
+ messageCount,
556
+ workspacePath: resolveTargetCwd(convData.entries[0]?.workspace),
557
+ recordedCwd: convData.entries[0]?.workspace,
558
+ threadKind: 'unknown',
559
+ ...(sessionUsage ? { usage: sessionUsage } : {}),
560
+ ...(latestQuota ? { quota: latestQuota } : {}),
561
+ });
542
562
  }
543
- catch {
544
- messageCount = convData.entries.length * 2;
545
- }
546
- sessions.push({
547
- id: convId,
548
- assistant: 'antigravity',
549
- title: sanitizeSessionTitle(title),
550
- ...times.values(),
551
- messageCount,
552
- workspacePath: resolveTargetCwd(convData.entries[0]?.workspace),
553
- recordedCwd: convData.entries[0]?.workspace,
554
- threadKind: 'unknown',
555
- ...(sessionUsage ? { usage: sessionUsage } : {}),
556
- ...(latestQuota ? { quota: latestQuota } : {}),
557
- });
558
563
  }
564
+ catch { }
559
565
  }
560
- catch { }
561
566
  // ─── 2. Scan Claude Code Sessions ──────────────────────────────────────
562
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
563
- const claudeProjectsDir = path.join(claudeConfigDir, 'projects');
564
- try {
565
- const candidateTargets = [
566
- { folder: getClaudeProjectFolderName(workspacePath), targetPath: workspacePath },
567
- ...repoPaths.map(r => ({ folder: getClaudeProjectFolderName(r), targetPath: r }))
568
- ];
569
- for (const { folder, targetPath } of candidateTargets) {
570
- const projectPath = path.join(claudeProjectsDir, folder);
571
- try {
572
- const files = await fs.readdir(projectPath);
573
- const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
574
- for (const file of jsonlFiles) {
575
- const filePath = path.join(projectPath, file);
576
- const sessionId = path.basename(file, '.jsonl');
577
- try {
578
- const content = await fs.readFile(filePath, 'utf-8');
579
- const lines = content.split('\n').filter(Boolean);
580
- let messageCount = 0;
581
- const times = sessionTimes();
582
- let title = 'Claude Session';
583
- let recordedCwd;
584
- let threadKind = file.startsWith('agent-') ? 'subagent' : 'unknown';
585
- let sessionUsage;
586
- let latestQuota;
587
- for (const line of lines) {
588
- let record;
589
- try {
590
- record = JSON.parse(line);
591
- }
592
- catch {
593
- continue;
594
- }
595
- times.add(record.timestamp);
596
- if (record.sessionId === sessionId && typeof record.cwd === 'string')
597
- recordedCwd ??= record.cwd;
598
- if (record.isSidechain === true)
599
- threadKind = 'subagent';
600
- else if (record.isSidechain === false && threadKind === 'unknown')
601
- threadKind = 'main';
602
- if (record.type === 'user' || record.type === 'assistant') {
603
- const text = claudeRecordText(record);
604
- // CLI-injected caveats, slash commands and tool results aren't
605
- // real turns — exclude them from the count and the title.
606
- const isNoise = record.type === 'user' && isNoiseUserRecord(record, text);
607
- if (isNoise)
567
+ if (!source || source === 'claude') {
568
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
569
+ const claudeProjectsDir = path.join(claudeConfigDir, 'projects');
570
+ try {
571
+ const candidateTargets = [
572
+ { folder: getClaudeProjectFolderName(workspacePath), targetPath: workspacePath },
573
+ ...repoPaths.map(r => ({ folder: getClaudeProjectFolderName(r), targetPath: r }))
574
+ ];
575
+ for (const { folder, targetPath } of candidateTargets) {
576
+ const projectPath = path.join(claudeProjectsDir, folder);
577
+ try {
578
+ const files = await fs.readdir(projectPath);
579
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
580
+ for (const file of jsonlFiles) {
581
+ const filePath = path.join(projectPath, file);
582
+ const sessionId = path.basename(file, '.jsonl');
583
+ try {
584
+ const content = await fs.readFile(filePath, 'utf-8');
585
+ const lines = content.split('\n').filter(Boolean);
586
+ let messageCount = 0;
587
+ const times = sessionTimes();
588
+ let title = 'Claude Session';
589
+ let recordedCwd;
590
+ let threadKind = file.startsWith('agent-') ? 'subagent' : 'unknown';
591
+ let sessionUsage;
592
+ let latestQuota;
593
+ for (const line of lines) {
594
+ let record;
595
+ try {
596
+ record = JSON.parse(line);
597
+ }
598
+ catch {
608
599
  continue;
609
- messageCount++;
610
- if (record.type === 'user' && title === 'Claude Session' && text.trim()) {
611
- title = text.trim();
612
600
  }
601
+ times.add(record.timestamp);
602
+ if (record.sessionId === sessionId && typeof record.cwd === 'string')
603
+ recordedCwd ??= record.cwd;
604
+ if (record.isSidechain === true)
605
+ threadKind = 'subagent';
606
+ else if (record.isSidechain === false && threadKind === 'unknown')
607
+ threadKind = 'main';
608
+ if (record.type === 'user' || record.type === 'assistant') {
609
+ const text = claudeRecordText(record);
610
+ // CLI-injected caveats, slash commands and tool results aren't
611
+ // real turns — exclude them from the count and the title.
612
+ const isNoise = record.type === 'user' && isNoiseUserRecord(record, text);
613
+ if (isNoise)
614
+ continue;
615
+ messageCount++;
616
+ if (record.type === 'user' && title === 'Claude Session' && text.trim()) {
617
+ title = text.trim();
618
+ }
619
+ }
620
+ const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
621
+ if (turnUsage)
622
+ sessionUsage = accumulateUsage(sessionUsage, turnUsage);
623
+ if (turnQuota)
624
+ latestQuota = turnQuota;
613
625
  }
614
- const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
615
- if (turnUsage)
616
- sessionUsage = accumulateUsage(sessionUsage, turnUsage);
617
- if (turnQuota)
618
- latestQuota = turnQuota;
626
+ sessions.push({
627
+ id: sessionId,
628
+ assistant: 'claude',
629
+ title: sanitizeSessionTitle(title),
630
+ ...times.values(),
631
+ messageCount,
632
+ workspacePath: targetPath,
633
+ recordedCwd,
634
+ threadKind,
635
+ ...(sessionUsage ? { usage: sessionUsage } : {}),
636
+ ...(latestQuota ? { quota: latestQuota } : {}),
637
+ });
619
638
  }
620
- sessions.push({
621
- id: sessionId,
622
- assistant: 'claude',
623
- title: sanitizeSessionTitle(title),
624
- ...times.values(),
625
- messageCount,
626
- workspacePath: targetPath,
627
- recordedCwd,
628
- threadKind,
629
- ...(sessionUsage ? { usage: sessionUsage } : {}),
630
- ...(latestQuota ? { quota: latestQuota } : {}),
631
- });
639
+ catch { }
632
640
  }
633
- catch { }
634
641
  }
642
+ catch { }
635
643
  }
636
- catch { }
637
644
  }
645
+ catch { }
638
646
  }
639
- catch { }
640
647
  // ─── 3. OpenAI Codex Sessions (rollout-*.jsonl) ──────────────────────────
641
- // Codex writes a `session_meta` record (payload.cwd) followed by
642
- // `response_item` records (payload.type==='message', role, content[].text).
643
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
644
- const codexSessionsDir = path.join(codexHome, 'sessions');
645
- try {
646
- const codexFiles = await getFilesRecursively(codexSessionsDir, '.jsonl');
647
- // Sort candidate rollout files so most recent / newest are scanned first
648
- const sortedCodexFiles = [...codexFiles].sort((a, b) => b.localeCompare(a));
649
- const MAX_CODEX_SCAN = 200;
650
- const filesToScan = sortedCodexFiles.slice(0, MAX_CODEX_SCAN);
651
- for (const file of filesToScan) {
652
- try {
653
- const header = await codexSessionHeader(file);
654
- // Recent rollouts record cwd up front. Skip unrelated conversations
655
- // before reading and parsing their full, sometimes very large logs.
656
- if (header?.type === 'session_meta' && typeof header.payload?.cwd === 'string'
657
- && !isPathMatch(header.payload.cwd))
658
- continue;
659
- const content = await fs.readFile(file, 'utf-8');
660
- const lines = content.split('\n').filter(Boolean);
661
- let sessionCwd = null;
662
- let sessionId = null;
663
- let messageCount = 0;
664
- const times = sessionTimes();
665
- let title = 'Codex Session';
666
- let identity = { threadKind: 'unknown' };
667
- let sessionUsage;
668
- let reportedTotalUsage;
669
- let latestQuota;
670
- for (const line of lines) {
671
- let record;
672
- try {
673
- record = JSON.parse(line);
674
- }
675
- catch {
648
+ if (!source || source === 'codex') {
649
+ // Codex writes a `session_meta` record (payload.cwd) followed by
650
+ // `response_item` records (payload.type==='message', role, content[].text).
651
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
652
+ const codexSessionsDir = path.join(codexHome, 'sessions');
653
+ try {
654
+ const codexFiles = await getFilesRecursively(codexSessionsDir, '.jsonl');
655
+ // Sort candidate rollout files so most recent / newest are scanned first
656
+ const sortedCodexFiles = [...codexFiles].sort((a, b) => b.localeCompare(a));
657
+ const MAX_CODEX_SCAN = 200;
658
+ const filesToScan = sortedCodexFiles.slice(0, MAX_CODEX_SCAN);
659
+ for (const file of filesToScan) {
660
+ try {
661
+ const header = await codexSessionHeader(file);
662
+ // Recent rollouts record cwd up front. Skip unrelated conversations
663
+ // before reading and parsing their full, sometimes very large logs.
664
+ if (header?.type === 'session_meta' && typeof header.payload?.cwd === 'string'
665
+ && !isPathMatch(header.payload.cwd))
676
666
  continue;
677
- }
678
- times.add(record.timestamp);
679
- times.add(record.payload?.timestamp);
680
- if (record.type === 'session_meta') {
681
- if (record.payload?.cwd)
682
- sessionCwd = record.payload.cwd;
683
- sessionId = codexSessionId(record) ?? sessionId;
684
- identity = codexThreadIdentity(record.payload);
685
- }
686
- else if (record.type === 'response_item' && record.payload?.type === 'message') {
687
- const role = record.payload.role;
688
- if (role !== 'user' && role !== 'assistant')
689
- continue;
690
- const text = codexMessageText(record.payload).trim();
691
- if (role === 'user' && (!text || isInjectedContextText(text)))
667
+ const content = await fs.readFile(file, 'utf-8');
668
+ const lines = content.split('\n').filter(Boolean);
669
+ let sessionCwd = null;
670
+ let sessionId = null;
671
+ let messageCount = 0;
672
+ const times = sessionTimes();
673
+ let title = 'Codex Session';
674
+ let identity = { threadKind: 'unknown' };
675
+ let sessionUsage;
676
+ let reportedTotalUsage;
677
+ let latestQuota;
678
+ for (const line of lines) {
679
+ let record;
680
+ try {
681
+ record = JSON.parse(line);
682
+ }
683
+ catch {
692
684
  continue;
693
- messageCount++;
694
- if (role === 'user' && title === 'Codex Session' && text) {
695
- title = text;
696
685
  }
697
- }
698
- // Interactive Codex writes cumulative totals in token_count events,
699
- // rather than a top-level usage field. Keep the latest total instead
700
- // of summing snapshots from each turn.
701
- if (record.type === 'event_msg' && record.payload?.type === 'token_count') {
702
- const total = record.payload.info?.total_token_usage;
703
- if (total) {
704
- const parsed = extractRecordUsage({ usage: total }).usage;
705
- if (parsed) {
706
- if (typeof total.cache_write_input_tokens === 'number') {
707
- parsed.cachedInputTokens = (typeof total.cached_input_tokens === 'number' ? total.cached_input_tokens : 0)
708
- + total.cache_write_input_tokens;
686
+ times.add(record.timestamp);
687
+ times.add(record.payload?.timestamp);
688
+ if (record.type === 'session_meta') {
689
+ if (record.payload?.cwd)
690
+ sessionCwd = record.payload.cwd;
691
+ sessionId = codexSessionId(record) ?? sessionId;
692
+ identity = codexThreadIdentity(record.payload);
693
+ }
694
+ else if (record.type === 'response_item' && record.payload?.type === 'message') {
695
+ const role = record.payload.role;
696
+ if (role !== 'user' && role !== 'assistant')
697
+ continue;
698
+ const text = codexMessageText(record.payload).trim();
699
+ if (role === 'user' && (!text || isInjectedContextText(text)))
700
+ continue;
701
+ messageCount++;
702
+ if (role === 'user' && title === 'Codex Session' && text) {
703
+ title = text;
704
+ }
705
+ }
706
+ // Interactive Codex writes cumulative totals in token_count events,
707
+ // rather than a top-level usage field. Keep the latest total instead
708
+ // of summing snapshots from each turn.
709
+ if (record.type === 'event_msg' && record.payload?.type === 'token_count') {
710
+ const total = record.payload.info?.total_token_usage;
711
+ if (total) {
712
+ const parsed = extractRecordUsage({ usage: total }).usage;
713
+ if (parsed) {
714
+ if (typeof total.cache_write_input_tokens === 'number') {
715
+ parsed.cachedInputTokens = (typeof total.cached_input_tokens === 'number' ? total.cached_input_tokens : 0)
716
+ + total.cache_write_input_tokens;
717
+ }
718
+ reportedTotalUsage = parsed;
709
719
  }
710
- reportedTotalUsage = parsed;
711
720
  }
712
721
  }
722
+ const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
723
+ if (turnUsage)
724
+ sessionUsage = accumulateUsage(sessionUsage, turnUsage);
725
+ if (turnQuota)
726
+ latestQuota = turnQuota;
713
727
  }
714
- const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
715
- if (turnUsage)
716
- sessionUsage = accumulateUsage(sessionUsage, turnUsage);
717
- if (turnQuota)
718
- latestQuota = turnQuota;
728
+ // Match on the recorded cwd; fall back to a content scan for older files.
729
+ const matched = sessionCwd
730
+ ? isPathMatch(sessionCwd)
731
+ : (content.toLowerCase().includes(normWorkspace) || content.includes(wsFolderName));
732
+ if (!matched)
733
+ continue;
734
+ // Skip contentless sessions (only injected context, no real turns).
735
+ if (messageCount === 0)
736
+ continue;
737
+ // Current rollout filenames include timestamps. The stable resume id is
738
+ // session_meta.payload.id, not the filename.
739
+ if (!sessionId)
740
+ continue;
741
+ sessions.push({
742
+ id: sessionId,
743
+ assistant: 'codex',
744
+ title: sanitizeSessionTitle(title),
745
+ ...times.values(),
746
+ messageCount,
747
+ workspacePath: resolveTargetCwd(sessionCwd || undefined),
748
+ recordedCwd: sessionCwd ?? undefined,
749
+ ...identity,
750
+ ...((reportedTotalUsage ?? sessionUsage) ? { usage: reportedTotalUsage ?? sessionUsage } : {}),
751
+ ...(latestQuota ? { quota: latestQuota } : {}),
752
+ });
719
753
  }
720
- // Match on the recorded cwd; fall back to a content scan for older files.
721
- const matched = sessionCwd
722
- ? isPathMatch(sessionCwd)
723
- : (content.toLowerCase().includes(normWorkspace) || content.includes(wsFolderName));
724
- if (!matched)
725
- continue;
726
- // Skip contentless sessions (only injected context, no real turns).
727
- if (messageCount === 0)
728
- continue;
729
- // Current rollout filenames include timestamps. The stable resume id is
730
- // session_meta.payload.id, not the filename.
731
- if (!sessionId)
732
- continue;
733
- sessions.push({
734
- id: sessionId,
735
- assistant: 'codex',
736
- title: sanitizeSessionTitle(title),
737
- ...times.values(),
738
- messageCount,
739
- workspacePath: resolveTargetCwd(sessionCwd || undefined),
740
- recordedCwd: sessionCwd ?? undefined,
741
- ...identity,
742
- ...((reportedTotalUsage ?? sessionUsage) ? { usage: reportedTotalUsage ?? sessionUsage } : {}),
743
- ...(latestQuota ? { quota: latestQuota } : {}),
744
- });
754
+ catch { }
745
755
  }
746
- catch { }
747
756
  }
757
+ catch { }
748
758
  }
749
- catch { }
750
759
  // ─── 4. GitHub Copilot Sessions (SQLite session store) ───────────────────
751
- // Copilot keeps sessions in ~/.copilot/session-store.db: a `sessions` table
752
- // (id, cwd, summary, timestamps) and a `turns` table (user_message /
753
- // assistant_response per turn). Match on `cwd` like the other harnesses.
754
- const copilotDb = await openCopilotDb();
755
- if (copilotDb) {
756
- try {
757
- const rows = copilotDb.prepare('SELECT id, cwd, summary, created_at, updated_at FROM sessions').all();
758
- for (const row of rows) {
759
- if (!isPathMatch(row.cwd))
760
- continue;
761
- const counted = copilotDb.prepare(`SELECT
760
+ if (!source || source === 'copilot') {
761
+ // Copilot keeps sessions in ~/.copilot/session-store.db: a `sessions` table
762
+ // (id, cwd, summary, timestamps) and a `turns` table (user_message /
763
+ // assistant_response per turn). Match on `cwd` like the other harnesses.
764
+ const copilotDb = await openCopilotDb();
765
+ if (copilotDb) {
766
+ try {
767
+ const rows = copilotDb.prepare('SELECT id, cwd, summary, created_at, updated_at FROM sessions').all();
768
+ for (const row of rows) {
769
+ if (!isPathMatch(row.cwd))
770
+ continue;
771
+ const counted = copilotDb.prepare(`SELECT
762
772
  SUM(CASE WHEN TRIM(COALESCE(user_message, '')) != '' THEN 1 ELSE 0 END)
763
773
  + SUM(CASE WHEN TRIM(COALESCE(assistant_response, '')) != '' THEN 1 ELSE 0 END) AS n
764
774
  FROM turns WHERE session_id = ?`).get(row.id);
765
- const messageCount = Number(counted?.n ?? 0);
766
- let title = (row.summary || '').trim();
767
- if (!title) {
768
- const first = copilotDb.prepare(`SELECT user_message FROM turns
775
+ const messageCount = Number(counted?.n ?? 0);
776
+ let title = (row.summary || '').trim();
777
+ if (!title) {
778
+ const first = copilotDb.prepare(`SELECT user_message FROM turns
769
779
  WHERE session_id = ? AND TRIM(COALESCE(user_message, '')) != ''
770
780
  AND TRIM(user_message) NOT LIKE '<%'
771
781
  ORDER BY turn_index LIMIT 1`).get(row.id);
772
- title = (first?.user_message || '').trim();
782
+ title = (first?.user_message || '').trim();
783
+ }
784
+ // Skip contentless sessions (no summary and no turns).
785
+ if (!title && messageCount === 0)
786
+ continue;
787
+ if (!title)
788
+ title = 'Copilot Session';
789
+ sessions.push({
790
+ id: row.id,
791
+ assistant: 'copilot',
792
+ title: sanitizeSessionTitle(title),
793
+ createdAt: sessionTimestamp(row.created_at) ?? '',
794
+ updatedAt: sessionTimestamp(row.updated_at) ?? '',
795
+ messageCount,
796
+ workspacePath: resolveTargetCwd(row.cwd),
797
+ recordedCwd: row.cwd,
798
+ threadKind: 'unknown',
799
+ });
773
800
  }
774
- // Skip contentless sessions (no summary and no turns).
775
- if (!title && messageCount === 0)
776
- continue;
777
- if (!title)
778
- title = 'Copilot Session';
779
- sessions.push({
780
- id: row.id,
781
- assistant: 'copilot',
782
- title: sanitizeSessionTitle(title),
783
- createdAt: sessionTimestamp(row.created_at) ?? '',
784
- updatedAt: sessionTimestamp(row.updated_at) ?? '',
785
- messageCount,
786
- workspacePath: resolveTargetCwd(row.cwd),
787
- recordedCwd: row.cwd,
788
- threadKind: 'unknown',
789
- });
790
801
  }
791
- }
792
- catch {
793
- // Leave Copilot out rather than fail the whole listing.
794
- }
795
- finally {
796
- try {
797
- copilotDb.close();
802
+ catch {
803
+ // Leave Copilot out rather than fail the whole listing.
804
+ }
805
+ finally {
806
+ try {
807
+ copilotDb.close();
808
+ }
809
+ catch { }
798
810
  }
799
- catch { }
800
811
  }
801
812
  }
802
- // ─── 5. Scan Workspace .sessions Directory ───────────────────────────────
803
- try {
804
- const wsSessionsDir = path.join(workspacePath, '.sessions');
805
- const sessionFiles = await getFilesRecursively(wsSessionsDir, '.jsonl').catch(() => []);
806
- for (const filePath of sessionFiles) {
807
- try {
808
- const content = await fs.readFile(filePath, 'utf-8');
809
- const lines = content.split('\n').filter(Boolean);
810
- if (lines.length === 0)
811
- continue;
812
- let sessionId = path.basename(filePath, '.jsonl');
813
- let assistant = 'claude';
814
- let title = 'AI Session';
815
- let messageCount = 0;
816
- const times = sessionTimes();
817
- let sessionUsage;
818
- let latestQuota;
819
- for (const line of lines) {
820
- let record;
821
- try {
822
- record = JSON.parse(line);
823
- }
824
- catch {
813
+ // ─── Modular history readers ────────────────────────────────────────────
814
+ const scanContext = { roots: [workspacePath, ...repoPaths], matchesCwd: isPathMatch, targetCwd: resolveTargetCwd };
815
+ for (const [id, reader] of Object.entries(MODULAR_SESSION_READERS)) {
816
+ if (!source || source === id)
817
+ sessions.push(...await reader.list(scanContext));
818
+ }
819
+ // ─── Scan Workspace .sessions Directory ──────────────────────────────────
820
+ if (!source || source === 'workspace') {
821
+ try {
822
+ const wsSessionsDir = path.join(workspacePath, '.sessions');
823
+ const sessionFiles = await getFilesRecursively(wsSessionsDir, '.jsonl').catch(() => []);
824
+ for (const filePath of sessionFiles) {
825
+ try {
826
+ const content = await fs.readFile(filePath, 'utf-8');
827
+ const lines = content.split('\n').filter(Boolean);
828
+ if (lines.length === 0)
825
829
  continue;
830
+ let sessionId = path.basename(filePath, '.jsonl');
831
+ let assistant = 'claude';
832
+ let title = 'AI Session';
833
+ let messageCount = 0;
834
+ const times = sessionTimes();
835
+ let sessionUsage;
836
+ let latestQuota;
837
+ for (const line of lines) {
838
+ let record;
839
+ try {
840
+ record = JSON.parse(line);
841
+ }
842
+ catch {
843
+ continue;
844
+ }
845
+ times.add(record.timestamp);
846
+ if (record.sessionId)
847
+ sessionId = record.sessionId;
848
+ if (record.provider) {
849
+ const p = String(record.provider).toLowerCase();
850
+ if (p.includes('claude'))
851
+ assistant = 'claude';
852
+ else if (p.includes('codex'))
853
+ assistant = 'codex';
854
+ else if (p.includes('antigravity') || p.includes('gemini'))
855
+ assistant = 'antigravity';
856
+ else if (p.includes('copilot'))
857
+ assistant = 'copilot';
858
+ else if (p === 'pi')
859
+ assistant = 'pi';
860
+ }
861
+ if (record.userPrompt && title === 'AI Session') {
862
+ title = String(record.userPrompt).trim();
863
+ }
864
+ if (record.userPrompt || record.assistantResponse) {
865
+ messageCount += (record.userPrompt ? 1 : 0) + (record.assistantResponse ? 1 : 0);
866
+ }
867
+ const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
868
+ if (turnUsage)
869
+ sessionUsage = accumulateUsage(sessionUsage, turnUsage);
870
+ if (turnQuota)
871
+ latestQuota = turnQuota;
826
872
  }
827
- times.add(record.timestamp);
828
- if (record.sessionId)
829
- sessionId = record.sessionId;
830
- if (record.provider) {
831
- const p = String(record.provider).toLowerCase();
832
- if (p.includes('claude'))
833
- assistant = 'claude';
834
- else if (p.includes('codex'))
835
- assistant = 'codex';
836
- else if (p.includes('antigravity') || p.includes('gemini'))
837
- assistant = 'antigravity';
838
- else if (p.includes('copilot'))
839
- assistant = 'copilot';
840
- }
841
- if (record.userPrompt && title === 'AI Session') {
842
- title = String(record.userPrompt).trim();
843
- }
844
- if (record.userPrompt || record.assistantResponse) {
845
- messageCount += (record.userPrompt ? 1 : 0) + (record.assistantResponse ? 1 : 0);
873
+ if (!sessions.some((s) => s.id === sessionId)) {
874
+ sessions.push({
875
+ id: sessionId,
876
+ assistant,
877
+ title: sanitizeSessionTitle(title),
878
+ ...times.values(),
879
+ messageCount: messageCount || lines.length,
880
+ workspacePath,
881
+ threadKind: 'unknown',
882
+ ...(sessionUsage ? { usage: sessionUsage } : {}),
883
+ ...(latestQuota ? { quota: latestQuota } : {}),
884
+ });
846
885
  }
847
- const { usage: turnUsage, quota: turnQuota } = extractRecordUsage(record);
848
- if (turnUsage)
849
- sessionUsage = accumulateUsage(sessionUsage, turnUsage);
850
- if (turnQuota)
851
- latestQuota = turnQuota;
852
- }
853
- if (!sessions.some((s) => s.id === sessionId)) {
854
- sessions.push({
855
- id: sessionId,
856
- assistant,
857
- title: sanitizeSessionTitle(title),
858
- ...times.values(),
859
- messageCount: messageCount || lines.length,
860
- workspacePath,
861
- threadKind: 'unknown',
862
- ...(sessionUsage ? { usage: sessionUsage } : {}),
863
- ...(latestQuota ? { quota: latestQuota } : {}),
864
- });
865
886
  }
887
+ catch { }
866
888
  }
867
- catch { }
868
889
  }
890
+ catch { }
869
891
  }
870
- catch { }
871
892
  // Sort by updatedAt descending
872
893
  sessions.sort((a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0));
873
894
  return sessions;
@@ -879,7 +900,7 @@ export async function findSessions(workspacePath, repoPaths = []) {
879
900
  *
880
901
  * @param workspacePath - Root directory of the active workspace.
881
902
  * @param repoPaths - Directories of sub-repositories included in the workspace.
882
- * @returns A promise that resolves to an array of unique {@link AIAssistant} names.
903
+ * @returns A promise that resolves to an array of unique session assistant names.
883
904
  */
884
905
  // ─── High-Performance mtime-aware Session Caches ────────────────────────────
885
906
  let agHistoryCache = null;
@@ -1000,7 +1021,7 @@ async function getCodexCwds(codexHome) {
1000
1021
  *
1001
1022
  * @param workspacePath - Root directory of the active workspace.
1002
1023
  * @param repoPaths - Directories of sub-repositories included in the workspace.
1003
- * @returns A promise that resolves to an array of unique {@link AIAssistant} names.
1024
+ * @returns A promise that resolves to an array of unique session assistant names.
1004
1025
  */
1005
1026
  export async function findActiveAssistants(workspacePath, repoPaths = []) {
1006
1027
  const active = new Set();
@@ -1058,19 +1079,27 @@ export async function findActiveAssistants(workspacePath, repoPaths = []) {
1058
1079
  if (copilotCwds.some(isPathMatch)) {
1059
1080
  active.add('copilot');
1060
1081
  }
1082
+ for (const [id, reader] of Object.entries(MODULAR_SESSION_READERS)) {
1083
+ if (await reader.hasAny({ roots: [workspacePath, ...repoPaths], matchesCwd: isPathMatch }))
1084
+ active.add(id);
1085
+ }
1061
1086
  return Array.from(active);
1062
1087
  }
1063
1088
  /**
1064
1089
  * Reads and parses the full transcript for a specific assistant conversation session.
1065
1090
  *
1066
- * @param assistant - The name of the AI assistant ('antigravity', 'claude', 'codex', 'copilot').
1091
+ * @param assistant - The name of the AI assistant ('antigravity', 'claude', 'codex', 'copilot', 'pi').
1067
1092
  * @param sessionId - Unique ID of the conversation session.
1068
1093
  * @returns A promise that resolves to an array of {@link ChatMessage} objects.
1069
1094
  */
1070
1095
  export async function getSessionTranscript(assistant, sessionId) {
1071
1096
  assertSafeSessionId(assistant, sessionId);
1072
1097
  const messages = [];
1073
- if (assistant === 'antigravity') {
1098
+ const modularReader = modularSessionReader(assistant);
1099
+ if (modularReader) {
1100
+ messages.push(...await modularReader.transcript(sessionId));
1101
+ }
1102
+ else if (assistant === 'antigravity') {
1074
1103
  const agDir = getAntigravityDir();
1075
1104
  const transcriptPath = path.join(agDir, 'brain', sessionId, '.system_generated', 'logs', 'transcript.jsonl');
1076
1105
  const content = await fs.readFile(transcriptPath, 'utf-8');