@qiaolei81/copilot-session-viewer 0.3.0 β†’ 0.3.2

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.2] - 2026-03-08
9
+
10
+ ### Fixed
11
+ - **VSCode Session Duration** - Duration now uses the last `terminalCommandState.timestamp` (when the agent actually executed a command) instead of a `toolCount Γ— 3500ms` heuristic. Long-running agentic sessions that span multiple hours are now measured correctly
12
+ - **VSCode Multi-Workspace Dedup** - `_findVsCodeSession` now collects candidates from all matching workspace hashes and returns the one with the latest effective end time (most complete data), instead of returning the first match found
13
+ - **Session `createdAt` in CI** - `Session.fromDirectory` now reads `startTime`/`endTime` from `workspace.yaml` (in addition to `created_at`/`updated_at`), fixing `createdAt` being undefined in environments where `stats.birthtime` is unavailable
14
+
15
+ ### Refactored
16
+ - **`_buildVsCodeSession()`** - Extracted shared VSCode session construction logic into a single method used by both the main scan loop and `_findVsCodeSession`, eliminating duplicate `effectiveEnd2`/`toolCount2` variables
17
+
18
+ ## [0.3.1] - 2026-03-07
19
+
20
+ ### Fixed
21
+ - **Session Deduplication** - VSCode sessions with the same ID across multiple workspaces are now deduplicated (keeps most recently updated)
22
+ - **WIP Status Accuracy** - VSCode agentic sessions now also check file mtime for WIP detection; threshold increased from 5 to 15 minutes
23
+ - **Timeline Bar Positioning** - UserReq rows with 0 tools no longer render at the start of the timeline
24
+ - **Tag Isolation** - Tags now use filePath-based storage to prevent shared directory collisions (Claude, Pi-Mono, Copilot CLI)
25
+ - **Per-Session Insight Files** - Agent review files use `{sessionId}.agent-review.md` naming to avoid collisions
26
+ - **Export All Sources** - Session export works for all sources including VSCode; file-based exports include `.tags.json`
27
+ - **Inline References in Markdown** - VSCode `inlineReference` items (file/folder links) now rendered as code references instead of being silently dropped, fixing broken markdown tables
28
+
29
+ ### Performance
30
+ - **60s Cache + Request Dedup** - `SessionRepository.findAll()` results cached for 60 seconds with concurrent request deduplication, reducing TTFB from ~11s to <100ms on cache hit
31
+
8
32
  ## [0.3.0] - 2026-03-07
9
33
 
10
34
  ### Added
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
  [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org/)
7
7
 
8
8
  **Multi-Tool Session Log Viewer & Analyzer**
9
- View and analyze AI coding assistant sessions from **GitHub Copilot CLI**, **Claude Code CLI**, and **Pi-Mono** with time analysis, virtual scrolling, and AI-powered insights.
9
+ View and analyze AI coding assistant sessions from **GitHub Copilot CLI**, **Copilot Chat (VSCode)**, **Claude Code CLI**, and **Pi-Mono** with time analysis, virtual scrolling, and AI-powered insights.
10
10
 
11
- A modern web-based viewer for analyzing AI coding assistant session logs with virtual scrolling, infinite loading, time analysis, and AI-powered insights. Supports **GitHub Copilot CLI**, **Claude Code CLI**, and **Pi-Mono** sessions.
11
+ A modern web-based viewer for analyzing AI coding assistant session logs with virtual scrolling, infinite loading, time analysis, and AI-powered insights. Supports **GitHub Copilot CLI**, **Copilot Chat (VSCode)**, **Claude Code CLI**, and **Pi-Mono** sessions.
12
12
 
13
13
  ### Session List
14
14
  ![Session List](https://raw.githubusercontent.com/qiaolei81/copilot-session-viewer/main/docs/images/homepage.png)
@@ -57,7 +57,7 @@ copilot-session-viewer
57
57
  - **πŸš€ Virtual Scrolling** - Handle 1000+ events smoothly
58
58
  - **♾️ Infinite Scroll** - Progressive session loading for better performance
59
59
  - **πŸ€– AI Insights** - LLM-powered session analysis
60
- - **🎭 Multi-Format Support** - Copilot, Claude Code, and Pi-Mono sessions
60
+ - **🎭 Multi-Format Support** - Copilot CLI, Copilot Chat (VSCode), Claude Code, and Pi-Mono sessions
61
61
 
62
62
  ### 🎨 **User Experience**
63
63
  - **πŸŒ™ Dark Theme** - GitHub-inspired interface
@@ -69,7 +69,7 @@ copilot-session-viewer
69
69
  - **Vue 3** - Reactive virtual scrolling
70
70
  - **Express.js** - Robust backend API
71
71
  - **ZIP Import/Export** - Session sharing capabilities with security validation
72
- - **Multi-Source Support** - Copilot (`~/.copilot/session-state/`), Claude (`~/.claude/projects/`), Pi-Mono (`~/.pi/agent/sessions/`)
72
+ - **Multi-Source Support** - Copilot CLI (`~/.copilot/session-state/`), Copilot Chat (`~/Library/Application Support/Code/User/workspaceStorage/`), Claude (`~/.claude/projects/`), Pi-Mono (`~/.pi/agent/sessions/`)
73
73
  - **Unified Event Format** - Consistent schema across all sources
74
74
  - **Memory Pagination** - Efficient handling of large sessions
75
75
  - **XSS Protection** - DOMPurify-based HTML sanitization
@@ -81,7 +81,8 @@ copilot-session-viewer
81
81
 
82
82
  1. **Generate Sessions** - Use GitHub Copilot CLI, Claude Code CLI, or Pi-Mono to create session logs
83
83
  2. **Auto-Discovery** - Sessions are automatically detected from:
84
- - Copilot: `~/.copilot/session-state/`
84
+ - Copilot CLI: `~/.copilot/session-state/`
85
+ - Copilot Chat: `~/Library/Application Support/Code/User/workspaceStorage/`
85
86
  - Claude: `~/.claude/projects/`
86
87
  - Pi-Mono: `~/.pi/agent/sessions/`
87
88
  3. **Browse & Analyze** - View sessions with infinite scroll and detailed event streams
@@ -124,8 +125,8 @@ This project includes comprehensive unit and E2E test coverage with CI/CD integr
124
125
 
125
126
  ### Test Coverage
126
127
 
127
- - **470+ Tests** (411 unit + 59 E2E)
128
- - **Unified Format Tests** - Mock data validation for all sources (Copilot, Claude, Pi-Mono)
128
+ - **700+ Tests** (622 unit + 80 E2E)
129
+ - **Unified Format Tests** - Mock data validation for all sources (Copilot CLI, Copilot Chat, Claude, Pi-Mono)
129
130
  - **Security Tests** - XSS prevention, ZIP bomb defense
130
131
  - **Integration Tests** - Session import/export, file operations
131
132
  - **CI-Friendly** - Mock data generation for reproducible tests
@@ -153,15 +154,15 @@ npm run test:all
153
154
 
154
155
  GitHub Actions workflow includes:
155
156
  1. **Linting** - ESLint code quality checks
156
- 2. **Unit Tests** - 411 Jest tests with coverage
157
+ 2. **Unit Tests** - 622 Jest tests with coverage
157
158
  3. **Mock Data Generation** - Reproducible test session fixtures
158
- 4. **E2E Tests** - 59 Playwright tests with Chromium
159
+ 4. **E2E Tests** - 80 Playwright tests with Chromium
159
160
  5. **Artifact Upload** - Test results on failure
160
161
 
161
162
  **Test Data Strategy:**
162
163
  - βœ… CI uses generated mock data (fast, reliable, no external dependencies)
163
164
  - βœ… Local development can use real sessions for integration testing
164
- - βœ… Fixtures cover all event formats (Copilot, Claude, Pi-Mono)
165
+ - βœ… Fixtures cover all event formats (Copilot CLI, Copilot Chat, Claude, Pi-Mono)
165
166
 
166
167
  ---
167
168
 
@@ -187,9 +188,10 @@ GitHub Actions workflow includes:
187
188
  ↕ File System
188
189
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
189
190
  β”‚ Data Layer (Multi-Source) β”‚
190
- β”‚ β€’ Copilot: ~/.copilot/session-state/ β”‚
191
- β”‚ β€’ Claude: ~/.claude/projects/ β”‚
192
- β”‚ β€’ Pi-Mono: ~/.pi/agent/sessions/ β”‚
191
+ β”‚ β€’ Copilot CLI: ~/.copilot/session-state/ β”‚
192
+ β”‚ β€’ Copilot Chat: ~/Library/.../workspaceStorage/ β”‚
193
+ β”‚ β€’ Claude: ~/.claude/projects/ β”‚
194
+ β”‚ β€’ Pi-Mono: ~/.pi/agent/sessions/ β”‚
193
195
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
194
196
  ```
195
197
 
@@ -304,12 +306,13 @@ MIT License - see [LICENSE](LICENSE) file for details
304
306
  - [DOMPurify](https://github.com/cure53/DOMPurify) - XSS protection
305
307
  - [Playwright](https://playwright.dev/) - E2E testing
306
308
 
307
- **Recent Updates (v0.1.9+):**
308
- - ✨ Multi-source support (Copilot, Claude, Pi-Mono)
309
+ **Recent Updates (v0.3.0):**
310
+ - ✨ VSCode Copilot Chat support (4th source)
311
+ - 🏷️ Session tagging system
312
+ - πŸ”„ Multi-tool branding (Copilot CLI, Copilot Chat, Claude Code, Pi-Mono)
309
313
  - πŸ”’ XSS protection with DOMPurify
310
314
  - πŸ›‘οΈ ZIP bomb defense (4-layer validation)
311
- - πŸ“„ Memory pagination API
312
- - πŸ§ͺ 470+ tests with CI/CD integration
315
+ - πŸ§ͺ 700+ tests with CI/CD integration
313
316
  - πŸ“š Comprehensive documentation
314
317
 
315
318
  ---
@@ -348,7 +348,14 @@ class VsCodeParser extends BaseSessionParser {
348
348
  case null: {
349
349
  // Plain markdown text item (no kind field, has 'value')
350
350
  const text = item.value || '';
351
- if (text) assistantText += text + '\n';
351
+ if (text) assistantText += text;
352
+ break;
353
+ }
354
+
355
+ case 'inlineReference': {
356
+ // File/folder reference inline in markdown β€” append name as code reference
357
+ const name = item.name || '';
358
+ if (name) assistantText += '`' + name + '`';
352
359
  break;
353
360
  }
354
361
 
@@ -413,7 +420,6 @@ class VsCodeParser extends BaseSessionParser {
413
420
  }
414
421
 
415
422
  case 'prepareToolInvocation':
416
- case 'inlineReference':
417
423
  case 'undoStop':
418
424
  case 'codeblockUri':
419
425
  case 'mcpServersStarting':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Web UI for viewing GitHub Copilot CLI session logs",
5
5
  "author": "Lei Qiao <qiaolei81@gmail.com>",
6
6
  "license": "MIT",
@@ -276,59 +276,72 @@ class SessionController {
276
276
  let sessionPath;
277
277
  let isDirectory = false;
278
278
 
279
- if (session.source === 'copilot') {
280
- const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
281
- if (!copilotSource) {
282
- return res.status(404).json({ error: 'Copilot source not found' });
283
- }
284
-
285
- const basePath = path.join(copilotSource.dir, sessionId);
279
+ if (session.directory) {
280
+ // Try session directory first (copilot dirs, vscode dirs)
286
281
  try {
287
- const stats = await fs.promises.stat(basePath);
282
+ const stats = await fs.promises.stat(session.directory);
288
283
  if (stats.isDirectory()) {
289
- sessionPath = basePath;
284
+ sessionPath = session.directory;
290
285
  isDirectory = true;
291
- } else {
292
- sessionPath = `${basePath}.jsonl`;
293
286
  }
294
287
  } catch {
295
- sessionPath = `${basePath}.jsonl`;
296
- }
297
- } else if (session.source === 'claude') {
298
- const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
299
- if (!claudeSource) {
300
- return res.status(404).json({ error: 'Claude source not found' });
301
- }
302
-
303
- // Claude sessions are in projects/*/sessionId.jsonl
304
- const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
305
- for (const projectDir of projectDirs) {
306
- const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
307
- try {
308
- await fs.promises.access(candidatePath);
309
- sessionPath = candidatePath;
310
- break;
311
- } catch {
312
- // Try next project
313
- }
288
+ // Fall through to filePath
314
289
  }
290
+ }
315
291
 
316
- if (!sessionPath) {
317
- return res.status(404).json({ error: 'Session file not found' });
318
- }
319
- } else if (session.source === 'pi-mono') {
320
- const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
321
- if (!piMonoSource) {
322
- return res.status(404).json({ error: 'Pi-Mono source not found' });
292
+ if (!sessionPath && session.filePath) {
293
+ // File-based sessions (claude .jsonl, pi-mono .jsonl, copilot .jsonl)
294
+ try {
295
+ await fs.promises.access(session.filePath);
296
+ sessionPath = session.filePath;
297
+ } catch {
298
+ // Not accessible
323
299
  }
300
+ }
324
301
 
325
- // Pi-Mono sessions are timestamp-based JSONL files
326
- const files = await fs.promises.readdir(piMonoSource.dir);
327
- const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
328
- if (!matchingFile) {
329
- return res.status(404).json({ error: 'Session file not found' });
302
+ // Legacy source-specific lookup as fallback
303
+ if (!sessionPath) {
304
+ if (session.source === 'copilot') {
305
+ const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
306
+ if (copilotSource) {
307
+ const basePath = path.join(copilotSource.dir, sessionId);
308
+ try {
309
+ const stats = await fs.promises.stat(basePath);
310
+ if (stats.isDirectory()) {
311
+ sessionPath = basePath;
312
+ isDirectory = true;
313
+ } else {
314
+ sessionPath = `${basePath}.jsonl`;
315
+ }
316
+ } catch {
317
+ sessionPath = `${basePath}.jsonl`;
318
+ }
319
+ }
320
+ } else if (session.source === 'claude') {
321
+ const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
322
+ if (claudeSource) {
323
+ const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
324
+ for (const projectDir of projectDirs) {
325
+ const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
326
+ try {
327
+ await fs.promises.access(candidatePath);
328
+ sessionPath = candidatePath;
329
+ break;
330
+ } catch {
331
+ // Try next project
332
+ }
333
+ }
334
+ }
335
+ } else if (session.source === 'pi-mono') {
336
+ const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
337
+ if (piMonoSource) {
338
+ const files = await fs.promises.readdir(piMonoSource.dir);
339
+ const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
340
+ if (matchingFile) {
341
+ sessionPath = path.join(piMonoSource.dir, matchingFile);
342
+ }
343
+ }
330
344
  }
331
- sessionPath = path.join(piMonoSource.dir, matchingFile);
332
345
  }
333
346
 
334
347
  if (!sessionPath) {
@@ -346,12 +359,23 @@ class SessionController {
346
359
  const zip = new AdmZip();
347
360
 
348
361
  if (isDirectory) {
349
- // Add entire directory
362
+ // Add entire directory (includes tags.json if present)
350
363
  zip.addLocalFolder(sessionPath, sessionId);
351
364
  } else {
352
- // Add single file
365
+ // Add session file
353
366
  const fileName = path.basename(sessionPath);
354
367
  zip.addLocalFile(sessionPath, '', fileName);
368
+
369
+ // Also include tags file if it exists
370
+ const TagService = require('../services/tagService');
371
+ const tagService = new TagService();
372
+ const tagsFilePath = tagService.getSessionTagsFilePath(session);
373
+ try {
374
+ await fs.promises.access(tagsFilePath);
375
+ zip.addLocalFile(tagsFilePath, '', path.basename(tagsFilePath));
376
+ } catch {
377
+ // No tags file, skip
378
+ }
355
379
  }
356
380
 
357
381
  // Send zip file
@@ -40,11 +40,21 @@ class Session {
40
40
  * @returns {Session}
41
41
  */
42
42
  static fromDirectory(dirPath, id, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus) {
43
+ const createdAt = workspace?.created_at
44
+ ? new Date(workspace.created_at)
45
+ : workspace?.startTime
46
+ ? new Date(workspace.startTime)
47
+ : stats.birthtime;
48
+ const updatedAt = workspace?.updated_at
49
+ ? new Date(workspace.updated_at)
50
+ : workspace?.endTime
51
+ ? new Date(workspace.endTime)
52
+ : stats.mtime;
43
53
  return new Session(id, 'directory', {
44
54
  directory: dirPath, // Add directory path
45
55
  workspace: workspace,
46
- createdAt: workspace?.created_at || stats.birthtime,
47
- updatedAt: workspace?.updated_at || stats.mtime,
56
+ createdAt,
57
+ updatedAt,
48
58
  summary: workspace?.summary || 'No summary',
49
59
  hasEvents: eventCount > 0,
50
60
  eventCount: eventCount,
@@ -72,6 +82,7 @@ class Session {
72
82
  */
73
83
  static fromFile(filePath, id, stats, eventCount, summary, duration, copilotVersion, selectedModel, sessionStatus) {
74
84
  return new Session(id, 'file', {
85
+ filePath: filePath,
75
86
  directory: path.dirname(filePath), // Directory containing the file
76
87
  createdAt: stats.birthtime,
77
88
  updatedAt: stats.mtime,
@@ -60,8 +60,9 @@ class InsightService {
60
60
  * @returns {Promise<Object>} Insight status and report
61
61
  */
62
62
  async generateInsight(sessionId, sessionPath, source = 'copilot', forceRegenerate = false) {
63
- const insightFile = path.join(sessionPath, 'agent-review.md');
64
- const lockFile = path.join(sessionPath, 'agent-review.md.lock');
63
+ // Use per-session insight file to avoid collisions in shared directories
64
+ const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
65
+ const lockFile = path.join(sessionPath, `${sessionId}.agent-review.md.lock`);
65
66
 
66
67
  // Determine events file location based on directory structure
67
68
  // Try standard events.jsonl first, then <sessionId>.jsonl (for file-type sessions),
@@ -184,7 +185,7 @@ class InsightService {
184
185
  await fs.mkdir(tmpDir, { recursive: true});
185
186
 
186
187
  const prompt = this._buildPrompt(insightFile, eventsFile);
187
- const outputFile = path.join(sessionPath, 'agent-review.md.tmp');
188
+ const outputFile = path.join(sessionPath, `${sessionId}.agent-review.md.tmp`);
188
189
 
189
190
  // Spawn analysis tool directly (no shell)
190
191
  const cliPath = toolConfig.cli;
@@ -451,17 +452,17 @@ IMPORTANT CONSTRAINTS:
451
452
  * @returns {Promise<Object>} Status object
452
453
  */
453
454
  async getInsightStatus(sessionId, sessionPath, _source = 'copilot') {
454
- return await this._getStatusForSource(sessionPath);
455
+ return await this._getStatusForSource(sessionId, sessionPath);
455
456
  }
456
457
 
457
458
  /**
458
459
  * Get status for a specific session directory
459
460
  * @private
460
461
  */
461
- async _getStatusForSource(sessionPath) {
462
- const insightFile = path.join(sessionPath, 'agent-review.md');
463
- const lockFile = path.join(sessionPath, 'agent-review.md.lock');
464
- const tmpFile = path.join(sessionPath, 'agent-review.md.tmp');
462
+ async _getStatusForSource(sessionId, sessionPath) {
463
+ const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
464
+ const lockFile = path.join(sessionPath, `${sessionId}.agent-review.md.lock`);
465
+ const tmpFile = path.join(sessionPath, `${sessionId}.agent-review.md.tmp`);
465
466
 
466
467
  try {
467
468
  const report = await fs.readFile(insightFile, 'utf-8');
@@ -517,7 +518,7 @@ IMPORTANT CONSTRAINTS:
517
518
  * @returns {Promise<Object>} Result object
518
519
  */
519
520
  async deleteInsight(sessionId, sessionPath, _source = 'copilot') {
520
- const insightFile = path.join(sessionPath, 'agent-review.md');
521
+ const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
521
522
 
522
523
  try {
523
524
  await fs.unlink(insightFile);
@@ -48,6 +48,23 @@ class SessionRepository {
48
48
  }
49
49
 
50
50
  this.parserFactory = new ParserFactory();
51
+
52
+ // Cache: keyed by sourceType (null = all sources)
53
+ this._cache = new Map();
54
+ this._cacheTTL = 60 * 1000; // 60 seconds
55
+ this._pendingScans = new Map(); // dedup concurrent requests
56
+ }
57
+
58
+ /**
59
+ * Invalidate cache (call after tag/insight changes if needed)
60
+ */
61
+ invalidateCache(sourceType = null) {
62
+ if (sourceType) {
63
+ this._cache.delete(sourceType);
64
+ this._cache.delete(null); // also invalidate "all" cache
65
+ } else {
66
+ this._cache.clear();
67
+ }
51
68
  }
52
69
 
53
70
  /**
@@ -56,6 +73,36 @@ class SessionRepository {
56
73
  * @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
57
74
  */
58
75
  async findAll(sourceType = null) {
76
+ const cacheKey = sourceType || '__all__';
77
+
78
+ // Check cache
79
+ const cached = this._cache.get(cacheKey);
80
+ if (cached && (Date.now() - cached.timestamp < this._cacheTTL)) {
81
+ return cached.data;
82
+ }
83
+
84
+ // Dedup concurrent scans for same key
85
+ if (this._pendingScans.has(cacheKey)) {
86
+ return this._pendingScans.get(cacheKey);
87
+ }
88
+
89
+ const scanPromise = this._doFindAll(sourceType).then(result => {
90
+ this._cache.set(cacheKey, { data: result, timestamp: Date.now() });
91
+ this._pendingScans.delete(cacheKey);
92
+ return result;
93
+ }).catch(err => {
94
+ this._pendingScans.delete(cacheKey);
95
+ throw err;
96
+ });
97
+
98
+ this._pendingScans.set(cacheKey, scanPromise);
99
+ return scanPromise;
100
+ }
101
+
102
+ /**
103
+ * @private
104
+ */
105
+ async _doFindAll(sourceType = null) {
59
106
  const allSessions = [];
60
107
 
61
108
  const sources = sourceType
@@ -71,7 +118,23 @@ class SessionRepository {
71
118
  }
72
119
  }
73
120
 
74
- return this._sortByUpdatedAt(allSessions);
121
+ return this._sortByUpdatedAt(this._deduplicateSessions(allSessions));
122
+ }
123
+
124
+ /**
125
+ * Deduplicate sessions with the same ID (e.g. VSCode sessions in multiple workspaces).
126
+ * Keeps the most recently updated session for each ID.
127
+ * @private
128
+ */
129
+ _deduplicateSessions(sessions) {
130
+ const seen = new Map();
131
+ for (const session of sessions) {
132
+ const existing = seen.get(session.id);
133
+ if (!existing || (session.updatedAt && existing.updatedAt && new Date(session.updatedAt) > new Date(existing.updatedAt))) {
134
+ seen.set(session.id, session);
135
+ }
136
+ }
137
+ return Array.from(seen.values());
75
138
  }
76
139
 
77
140
  /**
@@ -234,6 +297,7 @@ class SessionRepository {
234
297
 
235
298
  return new Session(sessionId, 'file', {
236
299
  source: 'claude',
300
+ filePath: fullPath,
237
301
  directory: path.dirname(fullPath), // Directory containing the session file
238
302
  workspace: {
239
303
  summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
@@ -415,6 +479,7 @@ class SessionRepository {
415
479
  'directory',
416
480
  {
417
481
  source: 'pi-mono',
482
+ filePath: filePath,
418
483
  directory: projectPath, // Project directory containing the session file
419
484
  workspace: { cwd: sessionEvent.cwd || projectName },
420
485
  createdAt: new Date(sessionEvent.timestamp),
@@ -447,6 +512,8 @@ class SessionRepository {
447
512
  async _findVsCodeSession(sessionId, workspaceStorageDir) {
448
513
  try {
449
514
  const hashes = await fs.readdir(workspaceStorageDir);
515
+ const candidates = [];
516
+
450
517
  for (const hash of hashes) {
451
518
  const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
452
519
  try {
@@ -464,58 +531,24 @@ class SessionRepository {
464
531
  sessionJson = JSON.parse(raw);
465
532
  }
466
533
  const requests = sessionJson.requests || [];
467
- if (requests.length === 0) return null;
468
-
469
- const firstReq = requests[0];
470
- const createdAt = sessionJson.creationDate
471
- ? new Date(sessionJson.creationDate)
472
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
473
- const lastReqTime2 = requests[requests.length - 1].timestamp ? new Date(requests[requests.length - 1].timestamp) : null;
474
- const updatedAt = sessionJson.lastMessageDate
475
- ? new Date(sessionJson.lastMessageDate)
476
- : (lastReqTime2 || stats.mtime);
477
- const userText = this._extractVsCodeUserText(firstReq.message);
478
-
479
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
480
- const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
534
+ if (requests.length === 0) continue;
481
535
 
482
- // Same estimation logic as scan path
483
- const _fileMtime2 = stats.mtime;
484
- // Count tools for estimation
485
- let toolCount2 = 0;
486
- for (const req of requests) {
487
- toolCount2 += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
488
- }
489
- let effectiveEnd2;
490
- if (toolCount2 > 10 && lastReqTime2) {
491
- const estimatedDurationMs2 = Math.max(toolCount2 * 3500, 60000);
492
- effectiveEnd2 = new Date(createdAt.getTime() + estimatedDurationMs2);
493
- } else if (lastReqTime2) {
494
- effectiveEnd2 = lastReqTime2;
495
- } else {
496
- effectiveEnd2 = updatedAt;
497
- }
498
-
499
- return new Session(sessionId, 'file', {
500
- source: 'vscode',
501
- filePath: fullPath,
502
- workspaceHash: hash,
503
- createdAt,
504
- updatedAt: effectiveEnd2,
505
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
506
- hasEvents: true,
507
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
508
- duration: effectiveEnd2.getTime() - createdAt.getTime(),
509
- sessionStatus: (Date.now() - effectiveEnd2.getTime()) < 5 * 60 * 1000 ? 'wip' : 'completed',
510
- selectedModel: firstReq.modelId || null,
511
- copilotVersion: copilotChatVersion,
512
- workspace: { cwd: realWorkspacePath || path.join(workspaceStorageDir, hash) },
513
- });
536
+ const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
537
+ const statsWithPath = { ...stats, filePath: fullPath };
538
+ candidates.push(this._buildVsCodeSession(
539
+ sessionId, requests, sessionJson, statsWithPath, hash,
540
+ realWorkspacePath || path.join(workspaceStorageDir, hash)
541
+ ));
514
542
  }
515
543
  } catch {
516
544
  // No chatSessions dir or can't read β€” skip
517
545
  }
518
546
  }
547
+ // Return the candidate with the latest effectiveEndTime (most complete data)
548
+ if (candidates.length > 0) {
549
+ candidates.sort((a, b) => (b.updatedAt?.getTime?.() ?? 0) - (a.updatedAt?.getTime?.() ?? 0));
550
+ return candidates[0];
551
+ }
519
552
  } catch (err) {
520
553
  console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
521
554
  }
@@ -592,7 +625,7 @@ class SessionRepository {
592
625
  const workspaceFile = path.join(fullPath, 'workspace.yaml');
593
626
  const eventsFile = path.join(fullPath, 'events.jsonl');
594
627
  const importedMarkerFile = path.join(fullPath, '.imported');
595
- const insightReportFile = path.join(fullPath, 'agent-review.md');
628
+ const insightReportFile = path.join(fullPath, `${entry}.agent-review.md`);
596
629
 
597
630
  // Parse workspace.yaml if exists, otherwise use defaults
598
631
  const workspace = await fileExists(workspaceFile)
@@ -620,6 +653,15 @@ class SessionRepository {
620
653
  if (!workspace.summary && optimizedMetadata.firstUserMessage) {
621
654
  workspace.summary = optimizedMetadata.firstUserMessage;
622
655
  }
656
+
657
+ // Use max of filesystem mtime and last event timestamp for updatedAt
658
+ if (optimizedMetadata.lastEventTime) {
659
+ const lastEventMs = new Date(optimizedMetadata.lastEventTime).getTime();
660
+ const mtimeMs = new Date(stats.mtime).getTime();
661
+ if (lastEventMs > mtimeMs) {
662
+ stats = { ...stats, mtime: new Date(lastEventMs) };
663
+ }
664
+ }
623
665
  }
624
666
 
625
667
  const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
@@ -897,68 +939,11 @@ class SessionRepository {
897
939
  const requests = sessionJson.requests || [];
898
940
  if (requests.length === 0) continue;
899
941
 
900
- const firstReq = requests[0];
901
- const lastReq = requests[requests.length - 1];
902
- const createdAt = sessionJson.creationDate
903
- ? new Date(sessionJson.creationDate)
904
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
905
- const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
906
- const updatedAt = sessionJson.lastMessageDate
907
- ? new Date(sessionJson.lastMessageDate)
908
- : (lastReqTime || stats.mtime);
909
-
910
- // Count tool invocations across all requests (must be before effectiveEndTime calc)
911
- let toolCount = 0;
912
- for (const req of requests) {
913
- toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
914
- }
915
-
916
- // For VSCode agentic sessions, file mtime may be more accurate than last request timestamp
917
- // because sub-agents write incremental updates over time without new request timestamps.
918
- // BUT: VSCode may touch/sync all files at once, creating misleading mtimes.
919
- // Strategy:
920
- // - If session has many tool invocations (agentic), estimate duration from tool count
921
- // - Otherwise fall back to request timestamps
922
- const _fileMtime = stats.mtime;
923
- let effectiveEndTime;
924
- if (toolCount > 10 && lastReqTime) {
925
- // Agentic session: estimate ~3.5s per tool invocation as a rough heuristic
926
- const estimatedDurationMs = Math.max(toolCount * 3500, 60000); // at least 1 min
927
- const estimatedEnd = new Date(createdAt.getTime() + estimatedDurationMs);
928
- effectiveEndTime = estimatedEnd;
929
- } else if (lastReqTime) {
930
- effectiveEndTime = lastReqTime;
931
- } else {
932
- effectiveEndTime = updatedAt;
933
- }
934
-
935
- const model = firstReq.modelId || null;
936
- const agentId = firstReq.agent?.id || 'vscode-copilot';
937
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
938
- const userText = this._extractVsCodeUserText(firstReq.message);
939
-
940
- const session = new Session(
941
- sessionId,
942
- 'file',
943
- {
944
- source: 'vscode',
945
- filePath: fullPath,
946
- workspaceHash,
947
- createdAt,
948
- updatedAt: effectiveEndTime,
949
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
950
- hasEvents: true,
951
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
952
- duration: effectiveEndTime.getTime() - createdAt.getTime(),
953
- sessionStatus: (Date.now() - effectiveEndTime.getTime()) < 5 * 60 * 1000 ? 'wip' : 'completed',
954
- selectedModel: model,
955
- agentId,
956
- toolCount,
957
- copilotVersion: copilotChatVersion,
958
- workspace: { cwd: realWorkspacePath || workspaceHashDir },
959
- }
942
+ const statsWithPath = { ...stats, filePath: fullPath };
943
+ const session = this._buildVsCodeSession(
944
+ sessionId, requests, sessionJson, statsWithPath, workspaceHash,
945
+ realWorkspacePath || workspaceHashDir
960
946
  );
961
-
962
947
  sessions.push(session);
963
948
  } catch (err) {
964
949
  // Skip malformed files silently
@@ -968,6 +953,76 @@ class SessionRepository {
968
953
  }
969
954
 
970
955
  /** Extract plain text from a VSCode message object */
956
+ /**
957
+ * Build a VSCode Session object from parsed JSONL data.
958
+ * Single source of truth for VSCode session construction β€” used by both
959
+ * the main scan loop and _findVsCodeSession to avoid duplicate logic.
960
+ * @private
961
+ */
962
+ _buildVsCodeSession(sessionId, requests, sessionJson, stats, workspaceHash, workspaceCwd) {
963
+ const firstReq = requests[0];
964
+ const lastReq = requests[requests.length - 1];
965
+
966
+ const createdAt = sessionJson.creationDate
967
+ ? new Date(sessionJson.creationDate)
968
+ : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
969
+
970
+ const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
971
+ const fallbackUpdatedAt = sessionJson.lastMessageDate
972
+ ? new Date(sessionJson.lastMessageDate)
973
+ : (lastReqTime || stats.mtime);
974
+
975
+ // Use last terminal command timestamp (truest end time for agentic sessions).
976
+ // terminalCommandState.timestamp = when the agent actually executed a command.
977
+ // request.timestamp = when the user sent the message (start of turn, not end).
978
+ // mtime is unreliable β€” VSCode syncs/touches all files when the workspace opens.
979
+ const lastTerminalTime = this._extractLastTerminalTimestamp(requests);
980
+ const effectiveEndTime = lastTerminalTime || lastReqTime || fallbackUpdatedAt;
981
+
982
+ const isWip = (Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000;
983
+ const userText = this._extractVsCodeUserText(firstReq.message);
984
+ const toolCount = requests.reduce(
985
+ (sum, req) => sum + (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length,
986
+ 0
987
+ );
988
+
989
+ return new Session(sessionId, 'file', {
990
+ source: 'vscode',
991
+ filePath: stats.filePath,
992
+ workspaceHash,
993
+ createdAt,
994
+ updatedAt: effectiveEndTime,
995
+ summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
996
+ hasEvents: true,
997
+ eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
998
+ duration: effectiveEndTime.getTime() - createdAt.getTime(),
999
+ sessionStatus: isWip ? 'wip' : 'completed',
1000
+ selectedModel: firstReq.modelId || null,
1001
+ agentId: firstReq.agent?.id || 'vscode-copilot',
1002
+ toolCount,
1003
+ copilotVersion: firstReq.agent?.extensionVersion || null,
1004
+ workspace: { cwd: workspaceCwd },
1005
+ });
1006
+ }
1007
+
1008
+ _extractLastTerminalTimestamp(requests) {
1009
+ let maxTs = 0;
1010
+
1011
+ function walk(obj) {
1012
+ if (!obj || typeof obj !== 'object') return;
1013
+ if (Array.isArray(obj)) { obj.forEach(walk); return; }
1014
+ if (obj.terminalCommandState && typeof obj.terminalCommandState.timestamp === 'number') {
1015
+ const ts = obj.terminalCommandState.timestamp;
1016
+ if (ts > 1_000_000_000_000 && ts < 9_999_999_999_999 && ts > maxTs) maxTs = ts;
1017
+ }
1018
+ for (const val of Object.values(obj)) walk(val);
1019
+ }
1020
+
1021
+ for (const req of requests) walk(req.response);
1022
+
1023
+ return maxTs > 0 ? new Date(maxTs) : null;
1024
+ }
1025
+
971
1026
  _extractVsCodeUserText(message) {
972
1027
  if (!message) return '';
973
1028
  if (typeof message.text === 'string') return message.text;
@@ -56,10 +56,18 @@ class TagService {
56
56
  * @returns {string} Path to tags.json
57
57
  */
58
58
  getSessionTagsFilePath(session) {
59
- if (!session.directory) {
60
- throw new Error('Session must have a directory field');
59
+ // File-based sessions (e.g. Claude .jsonl): per-file tags to avoid sharing
60
+ if (session.filePath) {
61
+ const dir = path.dirname(session.filePath);
62
+ const base = path.basename(session.filePath, path.extname(session.filePath));
63
+ return path.join(dir, `${base}.tags.json`);
61
64
  }
62
- return path.join(session.directory, 'tags.json');
65
+ // Directory-based sessions (e.g. Copilot CLI): tags.json inside session dir
66
+ if (session.directory) {
67
+ return path.join(session.directory, 'tags.json');
68
+ }
69
+ // Fallback: store in central location by session id
70
+ return path.join(this.knownTagsDir, 'session-tags', `${session.id}.tags.json`);
63
71
  }
64
72
 
65
73
  /**
@@ -130,6 +138,8 @@ class TagService {
130
138
  // File doesn't exist, ignore
131
139
  }
132
140
  } else {
141
+ // Ensure directory exists for tags file
142
+ await fs.mkdir(path.dirname(tagsFilePath), { recursive: true });
133
143
  // Write tags to session directory
134
144
  await fs.writeFile(tagsFilePath, JSON.stringify(normalizedTags, null, 2), 'utf8');
135
145
 
@@ -146,7 +146,8 @@ async function getSessionMetadataOptimized(filePath, maxMessageLength = 200) {
146
146
  copilotVersion: copilotVersion || null,
147
147
  selectedModel: selectedModel || null,
148
148
  hasSessionEnd,
149
- lastEventTime: lastTimestamp
149
+ lastEventTime: lastTimestamp,
150
+ firstEventTime: firstTimestamp
150
151
  };
151
152
  } catch (err) {
152
153
  console.error(`Error reading session metadata from ${filePath}:`, err.message);
@@ -2211,7 +2211,21 @@
2211
2211
  if (items[i].rowType === 'user-req') break; // next user-req
2212
2212
  if (items[i].rowType === 'subagent') children.push(items[i]);
2213
2213
  }
2214
- if (children.length === 0) return { left: '0%', width: '0%' };
2214
+ if (children.length === 0) {
2215
+ // No subagents β€” position this user-req at the end of previous subagents
2216
+ // by finding cumulative tool count up to this point
2217
+ const subagentItems = items.filter(it => it.rowType !== 'user-req');
2218
+ const totalToolCount = subagentItems.reduce((sum, it) => sum + (it.toolCount || 0), 0);
2219
+ if (totalToolCount === 0) return { left: '0%', width: '0%' };
2220
+ // Sum tools of all subagents before this user-req in the items array
2221
+ let cumTools = 0;
2222
+ for (let i = 0; i < idx; i++) {
2223
+ if (items[i].rowType === 'subagent') cumTools += (items[i].toolCount || 0);
2224
+ }
2225
+ const leftPct = (cumTools / totalToolCount) * 100;
2226
+ // Minimal width bar (at least 1%)
2227
+ return { left: leftPct + '%', width: Math.max(1, (1 / totalToolCount) * 100) + '%' };
2228
+ }
2215
2229
 
2216
2230
  const firstPos = ganttSequencePosition(children[0]);
2217
2231
  const lastPos = ganttSequencePosition(children[children.length - 1]);