@qiaolei81/copilot-session-viewer 0.3.1 → 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,16 @@ 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
+
8
18
  ## [0.3.1] - 2026-03-07
9
19
 
10
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.3.1",
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",
@@ -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,
@@ -512,6 +512,8 @@ class SessionRepository {
512
512
  async _findVsCodeSession(sessionId, workspaceStorageDir) {
513
513
  try {
514
514
  const hashes = await fs.readdir(workspaceStorageDir);
515
+ const candidates = [];
516
+
515
517
  for (const hash of hashes) {
516
518
  const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
517
519
  try {
@@ -529,58 +531,24 @@ class SessionRepository {
529
531
  sessionJson = JSON.parse(raw);
530
532
  }
531
533
  const requests = sessionJson.requests || [];
532
- if (requests.length === 0) return null;
533
-
534
- const firstReq = requests[0];
535
- const createdAt = sessionJson.creationDate
536
- ? new Date(sessionJson.creationDate)
537
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
538
- const lastReqTime2 = requests[requests.length - 1].timestamp ? new Date(requests[requests.length - 1].timestamp) : null;
539
- const updatedAt = sessionJson.lastMessageDate
540
- ? new Date(sessionJson.lastMessageDate)
541
- : (lastReqTime2 || stats.mtime);
542
- const userText = this._extractVsCodeUserText(firstReq.message);
543
-
544
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
545
- const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
534
+ if (requests.length === 0) continue;
546
535
 
547
- // Same estimation logic as scan path
548
- const _fileMtime2 = stats.mtime;
549
- // Count tools for estimation
550
- let toolCount2 = 0;
551
- for (const req of requests) {
552
- toolCount2 += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
553
- }
554
- let effectiveEnd2;
555
- if (toolCount2 > 10 && lastReqTime2) {
556
- const estimatedDurationMs2 = Math.max(toolCount2 * 3500, 60000);
557
- effectiveEnd2 = new Date(createdAt.getTime() + estimatedDurationMs2);
558
- } else if (lastReqTime2) {
559
- effectiveEnd2 = lastReqTime2;
560
- } else {
561
- effectiveEnd2 = updatedAt;
562
- }
563
-
564
- return new Session(sessionId, 'file', {
565
- source: 'vscode',
566
- filePath: fullPath,
567
- workspaceHash: hash,
568
- createdAt,
569
- updatedAt: effectiveEnd2,
570
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
571
- hasEvents: true,
572
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
573
- duration: effectiveEnd2.getTime() - createdAt.getTime(),
574
- sessionStatus: ((Date.now() - effectiveEnd2.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
575
- selectedModel: firstReq.modelId || null,
576
- copilotVersion: copilotChatVersion,
577
- workspace: { cwd: realWorkspacePath || path.join(workspaceStorageDir, hash) },
578
- });
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
+ ));
579
542
  }
580
543
  } catch {
581
544
  // No chatSessions dir or can't read — skip
582
545
  }
583
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
+ }
584
552
  } catch (err) {
585
553
  console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
586
554
  }
@@ -685,6 +653,15 @@ class SessionRepository {
685
653
  if (!workspace.summary && optimizedMetadata.firstUserMessage) {
686
654
  workspace.summary = optimizedMetadata.firstUserMessage;
687
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
+ }
688
665
  }
689
666
 
690
667
  const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
@@ -727,7 +704,7 @@ class SessionRepository {
727
704
  return 'completed';
728
705
  }
729
706
  if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
730
- const WIP_THRESHOLD_MS = 15 * 60 * 1000;
707
+ const WIP_THRESHOLD_MS = 5 * 60 * 1000;
731
708
  if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
732
709
  return 'wip';
733
710
  }
@@ -962,68 +939,11 @@ class SessionRepository {
962
939
  const requests = sessionJson.requests || [];
963
940
  if (requests.length === 0) continue;
964
941
 
965
- const firstReq = requests[0];
966
- const lastReq = requests[requests.length - 1];
967
- const createdAt = sessionJson.creationDate
968
- ? new Date(sessionJson.creationDate)
969
- : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
970
- const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
971
- const updatedAt = sessionJson.lastMessageDate
972
- ? new Date(sessionJson.lastMessageDate)
973
- : (lastReqTime || stats.mtime);
974
-
975
- // Count tool invocations across all requests (must be before effectiveEndTime calc)
976
- let toolCount = 0;
977
- for (const req of requests) {
978
- toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
979
- }
980
-
981
- // For VSCode agentic sessions, file mtime may be more accurate than last request timestamp
982
- // because sub-agents write incremental updates over time without new request timestamps.
983
- // BUT: VSCode may touch/sync all files at once, creating misleading mtimes.
984
- // Strategy:
985
- // - If session has many tool invocations (agentic), estimate duration from tool count
986
- // - Otherwise fall back to request timestamps
987
- const _fileMtime = stats.mtime;
988
- let effectiveEndTime;
989
- if (toolCount > 10 && lastReqTime) {
990
- // Agentic session: estimate ~3.5s per tool invocation as a rough heuristic
991
- const estimatedDurationMs = Math.max(toolCount * 3500, 60000); // at least 1 min
992
- const estimatedEnd = new Date(createdAt.getTime() + estimatedDurationMs);
993
- effectiveEndTime = estimatedEnd;
994
- } else if (lastReqTime) {
995
- effectiveEndTime = lastReqTime;
996
- } else {
997
- effectiveEndTime = updatedAt;
998
- }
999
-
1000
- const model = firstReq.modelId || null;
1001
- const agentId = firstReq.agent?.id || 'vscode-copilot';
1002
- const copilotChatVersion = firstReq.agent?.extensionVersion || null;
1003
- const userText = this._extractVsCodeUserText(firstReq.message);
1004
-
1005
- const session = new Session(
1006
- sessionId,
1007
- 'file',
1008
- {
1009
- source: 'vscode',
1010
- filePath: fullPath,
1011
- workspaceHash,
1012
- createdAt,
1013
- updatedAt: effectiveEndTime,
1014
- summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
1015
- hasEvents: true,
1016
- eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
1017
- duration: effectiveEndTime.getTime() - createdAt.getTime(),
1018
- sessionStatus: ((Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
1019
- selectedModel: model,
1020
- agentId,
1021
- toolCount,
1022
- copilotVersion: copilotChatVersion,
1023
- workspace: { cwd: realWorkspacePath || workspaceHashDir },
1024
- }
942
+ const statsWithPath = { ...stats, filePath: fullPath };
943
+ const session = this._buildVsCodeSession(
944
+ sessionId, requests, sessionJson, statsWithPath, workspaceHash,
945
+ realWorkspacePath || workspaceHashDir
1025
946
  );
1026
-
1027
947
  sessions.push(session);
1028
948
  } catch (err) {
1029
949
  // Skip malformed files silently
@@ -1033,6 +953,76 @@ class SessionRepository {
1033
953
  }
1034
954
 
1035
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
+
1036
1026
  _extractVsCodeUserText(message) {
1037
1027
  if (!message) return '';
1038
1028
  if (typeof message.text === 'string') return message.text;
@@ -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);