diff --git a/README.extension.md b/README.extension.md
index 3b126f3..e5c2194 100644
--- a/README.extension.md
+++ b/README.extension.md
@@ -55,6 +55,8 @@ The extension is organized into three sections: **Observe**, **Measure**, and **
| --- | --- |
| **Local Agent** | macOS: `~/Library/Application Support/Code/User/workspaceStorage/`
Linux: `~/.config/Code/User/workspaceStorage/`
Windows: `%APPDATA%\Code\User\workspaceStorage\` |
| **Local Agent (Insiders)** | macOS: `~/Library/Application Support/Code - Insiders/User/workspaceStorage/`
Linux: `~/.config/Code - Insiders/User/workspaceStorage/`
Windows: `%APPDATA%\Code - Insiders\User\workspaceStorage\` |
+| **Local Agent (Server)** | Linux/macOS remote host: `~/.vscode-server/data/User/workspaceStorage/` |
+| **Local Agent (Server Insiders)** | Linux/macOS remote host: `~/.vscode-server-insiders/data/User/workspaceStorage/` |
| **Xcode Copilot Chat** | `~/.config/github-copilot/xcode/` (requires `sqlite3`) |
| **Claude** | macOS/Linux: `~/.claude/projects/`
Windows: `%USERPROFILE%\.claude\projects\` |
| **Codex** | macOS/Linux: `~/.codex/sessions/`
Windows: `%USERPROFILE%\.codex\sessions\` |
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 72f29e2..34d003b 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -13,6 +13,7 @@ AI Engineer Coach reads logs from multiple AI coding tools:
| Harness | Source |
|---|---|
| **Local Agent / Local Agent (Insiders)** | Chat panel logs in the extension host directory (VS Code / VS Code Insiders) |
+| **Local Agent (Server) / Local Agent (Server Insiders)** | Remote host chat panel logs under `~/.vscode-server/data/User/workspaceStorage/` or `~/.vscode-server-insiders/data/User/workspaceStorage/` |
| **GitHub Copilot for Xcode** | Copilot Chat conversations from Apple's Xcode IDE |
| **Claude** | Session files from Anthropic's CLI-based coding assistant |
| **Codex** | Session history from OpenAI's terminal agent |
diff --git a/docs/content/getting-started/supported-tools.md b/docs/content/getting-started/supported-tools.md
index fdc71eb..55e81c8 100644
--- a/docs/content/getting-started/supported-tools.md
+++ b/docs/content/getting-started/supported-tools.md
@@ -12,6 +12,8 @@ AI Engineer Coach reads local log files from the following AI coding assistants.
The primary harness. AI Engineer Coach parses the chat panel logs that GitHub Copilot writes to the VS Code extension host log directory. This captures every request, response, model used, token counts, tool calls, file references, and terminal commands.
+When VS Code connects through Remote-WSL, Remote-SSH, or a Dev Container, the logs live on the remote host under `~/.vscode-server/data/User/workspaceStorage/` (or `~/.vscode-server-insiders/data/User/workspaceStorage/` for Insiders) and appear in the dashboard as `Local Agent (Server)` or `Local Agent (Server Insiders)`.
+
**What is tracked:**
- Requests and responses with timestamps
- Model selection (e.g., `claude-opus-4.6`, `gpt-5.4`, `auto`)
diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts
index 9547a90..239f059 100644
--- a/src/core/parser-vscode.test.ts
+++ b/src/core/parser-vscode.test.ts
@@ -9,7 +9,7 @@ import * as path from 'path';
import { describe, it, expect } from 'vitest';
import { reconstructFromJsonl } from './parser-vscode-files';
import { parseCLIEventsFile } from './parser-vscode-cli';
-import { parseSessionFile, harnessFromPath, scanVsCodeDirs } from './parser-vscode';
+import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs, parseTranscriptFile } from './parser-vscode';
function withTempFile(name: string, content: string, run: (filePath: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-'));
@@ -742,4 +742,167 @@ describe('parseSessionFile — skill detection', () => {
expect(session!.requests[0].skillsUsed).toContain('playwright-cli');
});
});
-});
\ No newline at end of file
+});
+describe('harnessFromPath — VS Code Server', () => {
+ it('returns "Local Agent (Server)" for .vscode-server paths', () => {
+ expect(harnessFromPath('/home/alice/.vscode-server/data/User/workspaceStorage')).toBe('Local Agent (Server)');
+ });
+
+ it('returns "Local Agent (Server Insiders)" for .vscode-server-insiders paths', () => {
+ expect(harnessFromPath('/home/alice/.vscode-server-insiders/data/User/workspaceStorage')).toBe('Local Agent (Server Insiders)');
+ });
+
+ it('does not match .vscode-server-insiders as plain .vscode-server', () => {
+ // .vscode-server-insiders contains the string ".vscode-server" — ensure
+ // the more-specific check fires first.
+ const result = harnessFromPath('/home/alice/.vscode-server-insiders/data/User/workspaceStorage');
+ expect(result).toBe('Local Agent (Server Insiders)');
+ expect(result).not.toBe('Local Agent (Server)');
+ });
+});
+
+describe('findVsCodeDirs — VS Code Server', () => {
+ it('includes server workspaceStorage paths on non-Windows hosts', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-vscode-'));
+ const home = process.env.HOME;
+ const userProfile = process.env.USERPROFILE;
+ const expected = [
+ path.join(root, '.config', 'Code', 'User', 'workspaceStorage'),
+ path.join(root, '.config', 'Code - Insiders', 'User', 'workspaceStorage'),
+ path.join(root, '.vscode-server', 'data', 'User', 'workspaceStorage'),
+ path.join(root, '.vscode-server-insiders', 'data', 'User', 'workspaceStorage'),
+ ];
+
+ for (const dir of expected) fs.mkdirSync(dir, { recursive: true });
+
+ process.env.HOME = root;
+ process.env.USERPROFILE = '';
+
+ try {
+ expect(findVsCodeDirs()).toEqual(expected);
+ } finally {
+ process.env.HOME = home;
+ process.env.USERPROFILE = userProfile;
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('parseTranscriptFile', () => {
+ it('converts transcript events into a session with user/assistant turns', () => {
+ const lines = [
+ JSON.stringify({
+ type: 'session.start',
+ id: 'ev-1',
+ timestamp: '2026-05-15T08:52:24.985Z',
+ data: { sessionId: 'transcript-session-1', version: 1, producer: 'copilot-agent' },
+ parentId: null,
+ }),
+ JSON.stringify({
+ type: 'user.message',
+ id: 'ev-2',
+ timestamp: '2026-05-15T08:52:30.000Z',
+ data: { content: 'Explain the parser architecture.', attachments: [] },
+ parentId: 'ev-1',
+ }),
+ JSON.stringify({
+ type: 'assistant.message',
+ id: 'ev-3',
+ timestamp: '2026-05-15T08:52:35.000Z',
+ data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'list_dir', type: 'function' }] },
+ parentId: 'ev-2',
+ }),
+ JSON.stringify({
+ type: 'tool.execution_start',
+ id: 'ev-4',
+ timestamp: '2026-05-15T08:52:35.100Z',
+ data: { toolCallId: 'tc-1', toolName: 'list_dir', arguments: { path: '/src' } },
+ parentId: 'ev-3',
+ }),
+ JSON.stringify({
+ type: 'tool.execution_complete',
+ id: 'ev-5',
+ timestamp: '2026-05-15T08:52:35.500Z',
+ data: { toolCallId: 'tc-1', success: true },
+ parentId: 'ev-4',
+ }),
+ JSON.stringify({
+ type: 'assistant.message',
+ id: 'ev-6',
+ timestamp: '2026-05-15T08:52:38.000Z',
+ data: { content: 'The parser reads JSONL files line by line.', toolRequests: [] },
+ parentId: 'ev-5',
+ }),
+ ].join('\n');
+
+ withTempFile('transcript-1.jsonl', lines, (filePath) => {
+ const session = parseTranscriptFile(filePath, 'ws-1', 'my-project', 'Local Agent (Server)');
+ expect(session).not.toBeNull();
+ expect(session!.sessionId).toBe('transcript-session-1');
+ expect(session!.workspaceId).toBe('ws-1');
+ expect(session!.workspaceName).toBe('my-project');
+ expect(session!.harness).toBe('Local Agent (Server)');
+ expect(session!.requests).toHaveLength(1);
+
+ const req = session!.requests[0];
+ expect(req.messageText).toBe('Explain the parser architecture.');
+ expect(req.responseText).toBe('The parser reads JSONL files line by line.');
+ expect(req.toolsUsed).toContain('list_dir');
+ expect(req.agentMode).toBe('agent');
+ expect(req.timestamp).toBe(new Date('2026-05-15T08:52:30.000Z').getTime());
+ });
+ });
+
+ it('groups multiple user turns into separate requests', () => {
+ const lines = [
+ JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'multi-turn' }, parentId: null }),
+ JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'First question.' }, parentId: 'e0' }),
+ JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z', data: { content: 'First answer.' }, parentId: 'e1' }),
+ JSON.stringify({ type: 'user.message', id: 'e3', timestamp: '2026-05-15T09:00:05.000Z', data: { content: 'Second question.' }, parentId: 'e2' }),
+ JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:06.000Z', data: { content: 'Second answer.' }, parentId: 'e3' }),
+ ].join('\n');
+
+ withTempFile('transcript-multi.jsonl', lines, (filePath) => {
+ const session = parseTranscriptFile(filePath, 'ws-2', 'proj', 'Local Agent (Server)');
+ expect(session).not.toBeNull();
+ expect(session!.requests).toHaveLength(2);
+ expect(session!.requests[0].messageText).toBe('First question.');
+ expect(session!.requests[0].responseText).toBe('First answer.');
+ expect(session!.requests[1].messageText).toBe('Second question.');
+ expect(session!.requests[1].responseText).toBe('Second answer.');
+ });
+ });
+
+ it('returns null for a session with no user messages', () => {
+ const lines = [
+ JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'empty-session' }, parentId: null }),
+ ].join('\n');
+
+ withTempFile('transcript-empty.jsonl', lines, (filePath) => {
+ expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull();
+ });
+ });
+
+ it('returns null for a file with no parseable events (all lines corrupt)', () => {
+ withTempFile('transcript-corrupt.jsonl', 'not json\nalso not json\n', (filePath) => {
+ expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull();
+ });
+ });
+
+ it('deduplicates tool names collected from both toolRequests and tool.execution_start', () => {
+ const lines = [
+ JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'dedup-tools' }, parentId: null }),
+ JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'Do work.' }, parentId: 'e0' }),
+ JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z',
+ data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'read_file', type: 'function' }] }, parentId: 'e1' }),
+ JSON.stringify({ type: 'tool.execution_start', id: 'e3', timestamp: '2026-05-15T09:00:03.000Z',
+ data: { toolCallId: 'tc-1', toolName: 'read_file', arguments: {} }, parentId: 'e2' }),
+ JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:04.000Z', data: { content: 'Done.' }, parentId: 'e3' }),
+ ].join('\n');
+
+ withTempFile('transcript-dedup.jsonl', lines, (filePath) => {
+ const session = parseTranscriptFile(filePath, 'ws-4', 'proj', 'Local Agent (Server)');
+ expect(session!.requests[0].toolsUsed).toEqual(['read_file']);
+ });
+ });
+});
diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts
index 048daca..b7c4c8a 100644
--- a/src/core/parser-vscode.ts
+++ b/src/core/parser-vscode.ts
@@ -20,6 +20,10 @@ function isObj(v: unknown): v is Record {
export function harnessFromPath(logsDir: string): string {
if (logsDir.includes('Code - Insiders')) return 'Local Agent (Insiders)';
+ // Check .vscode-server-insiders BEFORE .vscode-server — the latter is a
+ // substring of the former and would match incorrectly if checked first.
+ if (logsDir.includes('.vscode-server-insiders')) return 'Local Agent (Server Insiders)';
+ if (logsDir.includes('.vscode-server')) return 'Local Agent (Server)';
if (logsDir.includes('.copilot')) return 'GitHub Copilot CLI';
return 'Local Agent';
}
@@ -42,6 +46,15 @@ export function findVsCodeDirs(): string[] {
if (vsPath && fs.existsSync(vsPath) && !dirs.includes(vsPath)) dirs.push(vsPath);
}
+ // VS Code Server only runs on the remote host (Linux/macOS), not on Windows directly.
+ if (process.platform !== 'win32' && home) {
+ const serverEditions = ['.vscode-server', '.vscode-server-insiders'];
+ for (const serverDir of serverEditions) {
+ const serverPath = path.join(home, serverDir, 'data', 'User', 'workspaceStorage');
+ if (fs.existsSync(serverPath) && !dirs.includes(serverPath)) dirs.push(serverPath);
+ }
+ }
+
// Copilot CLI paths
const cliActive = path.join(home, '.copilot', 'session-state');
const cliLegacy = path.join(home, '.copilot', 'history-session-state');
@@ -143,6 +156,155 @@ function listEditStateFiles(esDir: string): string[] {
}
}
+function listTranscriptFiles(transcriptDir: string): string[] {
+ try {
+ return fs.readdirSync(transcriptDir, { withFileTypes: true })
+ .filter(e => e.isFile() && e.name.endsWith('.jsonl'))
+ .map(e => path.join(transcriptDir, e.name));
+ } catch {
+ return [];
+ }
+}
+
+interface TranscriptEvent {
+ type: string;
+ id?: string;
+ timestamp?: string;
+ data?: Record;
+}
+
+/** Parse the raw JSONL text of a transcript file into a list of events, skipping blank/corrupt lines. */
+function parseTranscriptLines(raw: string): TranscriptEvent[] {
+ const events: TranscriptEvent[] = [];
+ for (const line of raw.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ try { events.push(JSON.parse(trimmed) as TranscriptEvent); } catch { /* skip corrupt line */ }
+ }
+ return events;
+}
+
+/** Build a toolCallId → toolName index from tool.execution_start events. */
+function buildToolNameIndex(events: TranscriptEvent[]): Map {
+ const toolNames = new Map();
+ for (const ev of events) {
+ if (ev.type === 'tool.execution_start' && ev.data) {
+ const id = ev.data.toolCallId as string | undefined;
+ const name = ev.data.toolName as string | undefined;
+ if (id && name) toolNames.set(id, name);
+ }
+ }
+ return toolNames;
+}
+
+/** Collect tool names referenced in an assistant.message toolRequests array. */
+function collectToolsFromToolRequests(
+ toolRequests: unknown,
+ toolNames: Map,
+ out: string[],
+): void {
+ if (!Array.isArray(toolRequests)) return;
+ for (const tr of toolRequests as Array<{ toolCallId?: string; name?: string }>) {
+ const name = tr.name ?? (tr.toolCallId ? toolNames.get(tr.toolCallId) : undefined);
+ if (name) out.push(name);
+ }
+}
+
+/** Walk transcript events and group them into per-turn SessionRequests. */
+function buildRequestsFromTranscriptEvents(
+ events: TranscriptEvent[],
+ toolNames: Map,
+): SessionRequest[] {
+ const requests: SessionRequest[] = [];
+ let currentUserMsg: string | null = null;
+ let currentUserTs: number | null = null;
+ let currentUserMsgId: string | null = null;
+ let currentResponseParts: string[] = [];
+ let currentTools: string[] = [];
+
+ const flushTurn = () => {
+ if (currentUserMsg === null) return;
+ requests.push(createRequest({
+ requestId: currentUserMsgId ?? '',
+ timestamp: currentUserTs,
+ messageText: currentUserMsg,
+ responseText: currentResponseParts.join('').trim(),
+ toolsUsed: [...new Set(currentTools)],
+ agentMode: 'agent',
+ }));
+ currentUserMsg = null;
+ currentUserTs = null;
+ currentUserMsgId = null;
+ currentResponseParts = [];
+ currentTools = [];
+ };
+
+ for (const ev of events) {
+ if (ev.type === 'user.message') {
+ flushTurn();
+ currentUserMsg = (ev.data?.content as string | undefined) ?? '';
+ currentUserTs = ev.timestamp ? new Date(ev.timestamp).getTime() : null;
+ currentUserMsgId = ev.id ?? null;
+ } else if (ev.type === 'assistant.message') {
+ const content = (ev.data?.content as string | undefined) ?? '';
+ if (content) currentResponseParts.push(content);
+ // Collect tool names from inline toolRequests; fall back to the
+ // pre-indexed name when only a toolCallId is present.
+ collectToolsFromToolRequests(ev.data?.toolRequests, toolNames, currentTools);
+ } else if (ev.type === 'tool.execution_start') {
+ // Also push from execution_start so tool names appear even when the
+ // assistant.message toolRequests array is absent or empty.
+ const name = (ev.data?.toolName as string | undefined) ?? '';
+ if (name) currentTools.push(name);
+ }
+ }
+ flushTurn();
+
+ return requests;
+}
+
+/**
+ * Parses a Copilot Chat transcript JSONL file (GitHub.copilot-chat/transcripts/*.jsonl).
+ * Each file contains one session; events include session.start, user.message,
+ * assistant.message, tool.execution_start, and tool.execution_complete.
+ */
+export function parseTranscriptFile(
+ filePath: string,
+ wsId: string,
+ wsName: string,
+ harness: string,
+ customInstructionsBytes?: number,
+): Session | null {
+ let raw: string;
+ try { raw = readFile(filePath); } catch (e) {
+ debugCore('parser-vscode', `Cannot read transcript file ${filePath}`, e);
+ return null;
+ }
+
+ const events = parseTranscriptLines(raw);
+ if (events.length === 0) return null;
+
+ const sessionStart = events.find(e => e.type === 'session.start');
+ const sessionId = (sessionStart?.data?.sessionId as string | undefined) || path.basename(filePath, '.jsonl');
+ const sessionStartTs = sessionStart?.timestamp ? new Date(sessionStart.timestamp).getTime() : null;
+
+ const toolNames = buildToolNameIndex(events);
+ const requests = buildRequestsFromTranscriptEvents(events, toolNames);
+
+ if (requests.length === 0) return null;
+
+ return createSession({
+ sessionId,
+ workspaceId: wsId,
+ workspaceName: wsName,
+ harness,
+ requests,
+ creationDate: sessionStartTs,
+ location: 'panel',
+ customInstructionsBytes,
+ });
+}
+
function countLinesAdded(edits: { text?: string }[] | undefined): number {
let linesAdded = 0;
for (const edit of (edits || [])) {
@@ -257,6 +419,22 @@ export function processWorkspaceEntry(
}
}
+ // Transcript format: workspaceStorage//GitHub.copilot-chat/transcripts/*.jsonl
+ const transcriptDir = path.join(entryPath, 'GitHub.copilot-chat', 'transcripts');
+ for (const transcriptFile of listTranscriptFiles(transcriptDir)) {
+ const session = parseTranscriptFile(transcriptFile, wsId, wsName, harness, customInstructionsBytes);
+ if (session) {
+ sessions.push(session);
+ sessionSourceIndex.set(session.sessionId, {
+ kind: 'vscode-session-file',
+ filePath: transcriptFile,
+ workspaceId: wsId,
+ workspaceName: wsName,
+ harness,
+ });
+ }
+ }
+
const eventsFile = path.join(entryPath, 'events.jsonl');
const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes);
if (cliSession) {
@@ -305,9 +483,10 @@ export async function processWorkspaceEntryAsync(
}
const chatFiles = listChatSessionFiles(path.join(entryPath, 'chatSessions'));
+ const transcriptFiles = listTranscriptFiles(path.join(entryPath, 'GitHub.copilot-chat', 'transcripts'));
const editStateFiles = listEditStateFiles(path.join(entryPath, 'chatEditingSessions'));
- const totalUnits = Math.max(1, chatFiles.length + editStateFiles.length);
- const chatEvery = chunkInterval(chatFiles.length);
+ const totalUnits = Math.max(1, chatFiles.length + transcriptFiles.length + editStateFiles.length);
+ const chatEvery = chunkInterval(chatFiles.length + transcriptFiles.length);
const editEvery = chunkInterval(editStateFiles.length);
let completed = 0;
@@ -337,6 +516,30 @@ export async function processWorkspaceEntryAsync(
await yieldToLoop();
}
+ for (let i = 0; i < transcriptFiles.length; i++) {
+ const session = parseTranscriptFile(transcriptFiles[i], wsId, wsName, harness, customInstructionsBytes);
+ if (session) {
+ sessions.push(session);
+ sessionSourceIndex.set(session.sessionId, {
+ kind: 'vscode-session-file',
+ filePath: transcriptFiles[i],
+ workspaceId: wsId,
+ workspaceName: wsName,
+ harness,
+ });
+ }
+ completed++;
+ if (shouldReportChunk(chatFiles.length + i, chatFiles.length + transcriptFiles.length, chatEvery)) {
+ onProgress?.({
+ wsName,
+ detail: `transcript ${i + 1}/${transcriptFiles.length}`,
+ completed,
+ total: totalUnits,
+ });
+ }
+ await yieldToLoop();
+ }
+
const eventsFile = path.join(entryPath, 'events.jsonl');
const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes);
if (cliSession) {