diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..503f41e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,90 @@ +# Agent Flow — Project Guidelines + +## Overview + +Agent Flow is a VS Code extension that provides real-time visualization of AI agent orchestration (Claude Code, GitHub Copilot). It renders an interactive canvas showing agents, tool calls, subagents, and data flow as they execute. + +**Monorepo structure:** + +| Directory | Purpose | Language | +|-----------|---------|----------| +| `extension/` | VS Code extension host (event ingestion, hook server, webview host) | TypeScript (Node) | +| `web/` | React frontend (canvas visualization, panels, simulation) | TypeScript (React 19 + Next.js) | + +## Build & Test + +```bash +# Extension +cd extension +npm run build # esbuild → dist/extension.js +npm run watch # watch mode with source maps +npm run lint # tsc --noEmit type-check + +# Web / Webview +cd web +pnpm install +pnpm dev # Next.js dev server (localhost:3000) +pnpm build:webview # Vite IIFE bundle → extension/dist/webview/ + +# Full build (from extension/) +npm run build:all # webview + extension together +npm run package # .vsix package for distribution +``` + +## Architecture + +``` +Extension Host +├── Hook Server — TCP listener receives Claude Code hook POSTs +├── Session Watcher — Tails .jsonl transcript files from Claude Code cache +├── Copilot Watcher — Intercepts GitHub Copilot Chat tool invocations +├── Chat Participant — @agent-flow commands in Copilot Chat +├── LM Tools — agentFlow_getSessionStatus for Copilot queries +└── Webview Provider — Hosts React app (production bundle or dev iframe) + +Webview (React) +├── Canvas — HTML5 Canvas with d3-force layout, layered rendering +├── Simulation Hook — Processes AgentEvents into visual state +├── VSCode Bridge — postMessage communication with extension +└── UI Panels — Timeline, chat, transcript, file attention, controls +``` + +**Data flow:** Event sources → AgentEvent protocol → extension deduplicates → postMessage → webview simulation → canvas render loop. + +## Key Conventions + +### Extension (`extension/src/`) + +- **Logger:** Use `createLogger('ModuleName')` from `logger.ts` — never raw `console.log`. +- **Disposables:** Push all long-lived resources to `context.subscriptions` for cleanup. +- **Event deduplication:** Session watcher lifecycle events take priority over hook server. Hook server always passes through tool/message events from subagents. +- **Hook server responses:** Always return HTTP 200 with empty body — returning JSON causes Claude Code schema parsing issues. +- **No runtime deps:** Extension uses only Node.js built-ins + VS Code API. Keep it that way. +- **Constants:** Timeouts and limits live in `constants.ts`. Don't scatter magic numbers. + +### Web (`web/`) + +- **Canvas rendering:** All visualization drawn on HTML5 Canvas via modules in `components/agent-visualizer/canvas/`. Each `draw-*.ts` handles one layer. +- **Render cache:** Glow sprites and text measurements are cached in `render-cache.ts`. Use these caches — don't create CanvasGradient per frame. +- **Simulation state:** Managed in `hooks/use-agent-simulation.ts` with event processors split across `hooks/simulation/handle-*.ts`. +- **Colors:** All colors defined in `lib/colors.ts` (holographic sci-fi palette). Reference `COLORS.*` — don't hardcode hex values. +- **Layout constants:** Sizing, timing, animation, and force parameters in `lib/canvas-constants.ts`. +- **Component pattern:** Glass-morphism UI via `glass-card.tsx`. Panels are toggled mutually-exclusive from the main `index.tsx` orchestrator. +- **Bridge:** `lib/vscode-bridge.ts` is a singleton. Use the `use-vscode-bridge` hook in components. +- **Path alias:** `@/*` maps to `web/` root — use it for imports. + +### Protocol (`extension/src/protocol.ts`) + +Event types flow extension → webview: `agent_spawn`, `agent_complete`, `agent_idle`, `tool_call_start`, `tool_call_end`, `subagent_dispatch`, `subagent_return`, `message`, `context_update`, `model_detected`, `permission_requested`, `error`. + +When adding new event types, update both `protocol.ts` (extension) and `agent-types.ts` (web). + +## Styling + +Dark-only theme. Holographic sci-fi aesthetic with cyan primary (`#66ccff`), amber for tool calls, green for completions, red for errors. All panels use glass-morphism (semi-transparent backgrounds with subtle borders). + +## Existing Documentation + +- [README.md](../README.md) — Features, getting started, commands, settings, requirements +- [CONTRIBUTING.md](../CONTRIBUTING.md) — CLA, bug reports, PRs, code of conduct +- [extension/README.md](../extension/README.md) — Extension marketplace description diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a867ce1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Agent Flow Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/extension" + ], + "outFiles": [ + "${workspaceFolder}/extension/dist/**/*.js" + ], + "preLaunchTask": "npm: build - extension" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..04d56d6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "build", + "path": "extension", + "label": "npm: build - extension", + "group": "build" + }, + { + "type": "npm", + "script": "watch", + "path": "extension", + "label": "npm: watch - extension", + "isBackground": true, + "problemMatcher": "$esbuild-watch", + "group": "build" + } + ] +} diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index e87763a..d78d50f 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.5.0 + +- Feature: Agentic proxy for GitHub Copilot Chat — type `@agent-flow` followed by any prompt to visualize tool calls, messages, and model reasoning in real-time on the canvas +- Feature: Clickable timeline panel — click anywhere on the execution timeline to seek/scrub to that point in the session +- Enhancement: Message bubbles now persist 3x longer (30s hold, 3s fade-out) for easier reading +- Added `.github/copilot-instructions.md` with project guidelines for AI agent productivity +- Added `.vscode/launch.json` and `tasks.json` for Extension Development Host debugging +- Chat participant is now sticky — follow-up messages stay in `@agent-flow` context + ## 0.4.7 - Fix: reset button in review mode no longer breaks the extension diff --git a/extension/package.json b/extension/package.json index d752114..52d891b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "agent-flow", "displayName": "Agent Flow", "description": "Real-time visualization of Claude Code agent orchestration", - "version": "0.4.7", + "version": "0.5.0", "publisher": "simon-p", "license": "Apache-2.0", "repository": { @@ -11,15 +11,72 @@ }, "icon": "media/icon.png", "engines": { - "vscode": "^1.85.0" + "vscode": "^1.93.0" }, - "categories": ["Visualization", "Other"], - "keywords": ["llm", "agent", "claude", "ai", "visualization", "orchestration"], + "categories": [ + "Visualization", + "Chat", + "Other" + ], + "keywords": [ + "llm", + "agent", + "claude", + "copilot", + "ai", + "visualization", + "orchestration", + "chat" + ], "activationEvents": [ - "onWebviewPanel:agentVisualizer" + "onWebviewPanel:agentVisualizer", + "onChatParticipant:agent-flow.visualizer" ], "main": "./dist/extension.js", "contributes": { + "chatParticipants": [ + { + "id": "agent-flow.visualizer", + "fullName": "Agent Flow", + "name": "agent-flow", + "description": "Real-time visualization of AI agent orchestration — see active sessions, tool calls, and agent activity.", + "isSticky": true, + "commands": [ + { + "name": "status", + "description": "Show current agent session status and activity" + }, + { + "name": "open", + "description": "Open the Agent Flow visualizer panel" + }, + { + "name": "sessions", + "description": "List all active and recent agent sessions" + } + ] + } + ], + "languageModelTools": [ + { + "name": "agentFlow_getSessionStatus", + "description": "Returns the current status of agent sessions being visualized by Agent Flow, including active sessions, tool calls in progress, and connection state.", + "tags": [ + "agent-flow", + "sessions", + "status" + ], + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Optional session ID to get status for a specific session. If omitted, returns all active sessions." + } + } + } + } + ], "commands": [ { "command": "agentVisualizer.open", @@ -40,6 +97,11 @@ "command": "agentVisualizer.configureHooks", "title": "Configure Claude Code Hooks", "category": "Agent Flow" + }, + { + "command": "agentVisualizer.watchCopilotChat", + "title": "Watch Copilot Chat Activity", + "category": "Agent Flow" } ], "keybindings": [ @@ -66,12 +128,17 @@ "type": "boolean", "default": false, "description": "Automatically open the visualizer when an agent session starts" + }, + "agentVisualizer.watchCopilotChat": { + "type": "boolean", + "default": true, + "description": "Watch GitHub Copilot Chat activity and visualize tool calls and agent events" } } } }, "scripts": { - "vscode:prepublish": "cp ../README.md ./README.md && npm run build:all", + "vscode:prepublish": "node -e \"require('fs').copyFileSync('../README.md','./README.md')\" && npm run build:all", "build": "node esbuild.js", "build:webview": "cd ../web && pnpm run build:webview", "build:all": "npm run build:webview && npm run build", @@ -81,8 +148,8 @@ "vscode:uninstall": "node scripts/uninstall.js" }, "devDependencies": { - "@types/vscode": "^1.85.0", "@types/node": "^20.0.0", + "@types/vscode": "^1.93.0", "esbuild": "^0.20.0", "typescript": "^5.3.0" } diff --git a/extension/src/copilot-chat-participant.ts b/extension/src/copilot-chat-participant.ts new file mode 100644 index 0000000..0b9bcf8 --- /dev/null +++ b/extension/src/copilot-chat-participant.ts @@ -0,0 +1,323 @@ +import * as vscode from 'vscode' +import { createLogger } from './logger' +import type { CopilotWatcher } from './copilot-watcher' + +const log = createLogger('CopilotChat') + +const PARTICIPANT_ID = 'agent-flow.visualizer' +const MAX_TOOL_ROUNDS = 15 + +/** Deps injected from extension.ts */ +export interface ChatParticipantDeps { + getActiveSessions: () => Array<{ id: string; label: string; status: string; startTime: number; lastActivityTime: number }> + getHookPort: () => number + isSessionWatcherActive: () => boolean + isCopilotWatcherActive: () => boolean + copilotWatcher?: CopilotWatcher +} + +/** + * Registers an @agent-flow chat participant in GitHub Copilot Chat. + * + * Users can type @agent-flow in Copilot Chat to: + * /status — Show current session status + * /open — Open the visualizer panel + * /sessions — List active sessions + * (default) — Agentic proxy that visualizes every tool call on the canvas + */ +export function registerChatParticipant( + context: vscode.ExtensionContext, + deps: ChatParticipantDeps, +): vscode.Disposable { + const participant = vscode.chat.createChatParticipant(PARTICIPANT_ID, async ( + request: vscode.ChatRequest, + chatContext: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) => { + const command = request.command + + if (command === 'open') { + await vscode.commands.executeCommand('agentVisualizer.open') + stream.markdown('Opened the **Agent Flow** visualizer panel.') + return + } + + if (command === 'sessions') { + const sessions = deps.getActiveSessions() + if (sessions.length === 0) { + stream.markdown('No active agent sessions detected.\n\nTo start watching, open the visualizer with `/open` and begin an agent session.') + return + } + stream.markdown(`**Active Sessions (${sessions.length}):**\n\n`) + for (const s of sessions) { + const age = Math.round((Date.now() - s.startTime) / 1000) + stream.markdown(`- **${s.label || s.id.slice(0, 8)}** — ${s.status} (${age}s ago)\n`) + } + return + } + + if (command === 'status') { + return handleStatus(stream, deps) + } + + // Default: agentic proxy with real-time visualization + await handleAgenticProxy(request, chatContext, stream, token, deps.copilotWatcher) + }) + + participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'icon.png') + + log.info('Chat participant registered') + return participant +} + +// ─── Agentic proxy ──────────────────────────────────────────────────────────── + +// Runtime references to VS Code LM classes (available in VS Code 1.95+). +// The extension targets @types/vscode 1.93, so these don't exist in the typings. +// Resolved lazily (inside functions) — at module load time the API may not exist yet. +/* eslint-disable @typescript-eslint/no-explicit-any */ +function getLMTextPart(): (new (text: string) => any) | undefined { + return (vscode as any).LanguageModelTextPart +} +function getLMToolResultPart(): (new (callId: string, content: any) => any) | undefined { + return (vscode as any).LanguageModelToolResultPart +} +function getLmNs(): any { + return vscode.lm as any +} + +function hasToolSupport(): boolean { + const lm = getLmNs() + return typeof lm?.tools !== 'undefined' + && typeof lm?.invokeTool === 'function' + && !!getLMTextPart() + && !!getLMToolResultPart() +} + +/** + * Forward the user's prompt to the language model with full tool access. + * Each tool call is emitted as a visualization event on the Agent Flow canvas. + */ +async function handleAgenticProxy( + request: vscode.ChatRequest, + chatContext: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + watcher?: CopilotWatcher, +): Promise { + // ── 0. Check runtime API availability ────────────────────────────────── + log.info(`Tool support check: lm.tools=${typeof getLmNs()?.tools}, invokeTool=${typeof getLmNs()?.invokeTool}, TextPart=${!!getLMTextPart()}, ResultPart=${!!getLMToolResultPart()}`) + + if (!hasToolSupport()) { + log.warn('Tool API not available — falling back to non-agentic mode') + stream.markdown( + '**Agent Flow agentic mode** requires VS Code 1.95+ with tool API support.\n\n' + + 'Your VS Code may not expose `vscode.lm.tools`. Try updating VS Code Insiders.\n\n' + + 'Available commands: `/status`, `/open`, `/sessions`\n\n' + + 'Alternatively, use Claude Code hooks for real-time visualization.', + ) + return + } + + // ── 1. Select a language model ───────────────────────────────────────── + let model: vscode.LanguageModelChat | undefined + + // Prefer the model attached to the request (VS Code 1.95+) + try { + const reqModel = (request as any).model as vscode.LanguageModelChat | undefined + if (reqModel && typeof reqModel.sendRequest === 'function') { + model = reqModel + } + } catch { /* not available */ } + + if (!model) { + const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }) + model = models[0] + } + + if (!model) { + stream.markdown('No language model available. Make sure GitHub Copilot is signed in.') + return + } + + // ── 2. Ensure visualizer panel is open ───────────────────────────────── + await vscode.commands.executeCommand('agentVisualizer.open') + + // ── 3. Create a visualization session ────────────────────────────────── + const sessionLabel = request.prompt.length > 60 + ? request.prompt.slice(0, 57) + '...' + : request.prompt + const sessionId = watcher?.createSession(sessionLabel) ?? '' + + if (watcher && sessionId) { + watcher.emitModelDetected(sessionId, (model as any).name ?? (model as any).id ?? 'unknown') + watcher.emitMessage(sessionId, 'user', request.prompt) + } + + // ── 4. Build conversation from chat history ──────────────────────────── + const messages: vscode.LanguageModelChatMessage[] = [] + + for (const turn of chatContext.history) { + if (turn instanceof vscode.ChatRequestTurn) { + messages.push(vscode.LanguageModelChatMessage.User(turn.prompt)) + } else if (turn instanceof vscode.ChatResponseTurn) { + const parts = turn.response + .filter((p): p is vscode.ChatResponseMarkdownPart => p instanceof vscode.ChatResponseMarkdownPart) + .map(p => p.value.value) + const text = parts.join('') + if (text) { + messages.push(vscode.LanguageModelChatMessage.Assistant(text)) + } + } + } + + messages.push(vscode.LanguageModelChatMessage.User(request.prompt)) + + // ── 5. Gather available tools ────────────────────────────────────────── + const lm = getLmNs() + const tools: any[] = lm.tools ?? [] + const LMTextPart = getLMTextPart() + const LMToolResultPart = getLMToolResultPart() + + // ── 6. Agentic loop ──────────────────────────────────────────────────── + try { + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + if (token.isCancellationRequested) break + + const response: any = await model.sendRequest(messages, { tools } as any, token) + + let textAccumulator = '' + const toolCalls: any[] = [] + + // response.stream (1.95+) or response.text (1.93) — prefer stream + const responseStream: AsyncIterable = response.stream ?? response.text + for await (const part of responseStream) { + if (LMTextPart && part instanceof LMTextPart) { + stream.markdown(part.value) + textAccumulator += part.value + } else if (typeof part === 'string') { + // Fallback for older response.text iteration + stream.markdown(part) + textAccumulator += part + } else if (part?.callId || part?.name) { + // LanguageModelToolCallPart — has callId + name + input + toolCalls.push(part) + } + } + + // Emit assistant message to visualizer + if (textAccumulator && watcher && sessionId) { + watcher.emitMessage(sessionId, 'assistant', textAccumulator) + } + + // No tool calls → model is done + if (toolCalls.length === 0) break + + // Record the assistant turn (text + tool call requests) + const assistantParts: any[] = [] + if (textAccumulator && LMTextPart) { + assistantParts.push(new LMTextPart(textAccumulator)) + } + assistantParts.push(...toolCalls) + messages.push(vscode.LanguageModelChatMessage.Assistant(assistantParts as any)) + + // ── Execute each tool call ─────────────────────────────────────── + for (const toolCall of toolCalls) { + if (token.isCancellationRequested) break + + const toolCallId = watcher?.emitToolCallStart( + sessionId, toolCall.name, toolCall.input as Record, + ) ?? '' + + stream.progress(`Running ${toolCall.name}…`) + + try { + const invokeTool = lm.invokeTool as (name: string, opts: any, token: any) => Promise + const result = await invokeTool(toolCall.name, { + input: toolCall.input, + toolInvocationToken: (request as any).toolInvocationToken, + }, token) + + // Summarize result for the visualizer (keep it short) + let resultSummary = '' + if (result?.content && Array.isArray(result.content)) { + resultSummary = result.content + .filter((p: any) => typeof p?.value === 'string') + .map((p: any) => p.value) + .join('') + .slice(0, 200) + } + + if (watcher && sessionId) { + watcher.emitToolCallEnd(sessionId, toolCallId, toolCall.name, resultSummary) + } + + // Feed result back to the model + if (LMToolResultPart) { + messages.push( + vscode.LanguageModelChatMessage.User([ + new LMToolResultPart(toolCall.callId, result?.content ?? []), + ] as any), + ) + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + log.warn(`Tool ${toolCall.name} failed:`, errMsg) + + if (watcher && sessionId) { + watcher.emitToolCallEnd(sessionId, toolCallId, toolCall.name, `Error: ${errMsg}`) + } + + if (LMToolResultPart && LMTextPart) { + messages.push( + vscode.LanguageModelChatMessage.User([ + new LMToolResultPart(toolCall.callId, [ + new LMTextPart(`Error invoking ${toolCall.name}: ${errMsg}`), + ]), + ] as any), + ) + } + } + } + } + } catch (err) { + if (err instanceof vscode.CancellationError) { + log.info('Request cancelled') + } else { + const errMsg = err instanceof Error ? err.message : String(err) + log.error('Agentic proxy error:', errMsg) + stream.markdown(`\n\n*Error: ${errMsg}*`) + } + } finally { + if (watcher && sessionId) { + watcher.completeSession(sessionId) + } + } +} + +// ─── Status handler ─────────────────────────────────────────────────────────── + +function handleStatus( + stream: vscode.ChatResponseStream, + deps: ChatParticipantDeps, +): void { + const sessions = deps.getActiveSessions() + const hookPort = deps.getHookPort() + const sessionWatcherActive = deps.isSessionWatcherActive() + const copilotWatcherActive = deps.isCopilotWatcherActive() + + const sources: string[] = [] + if (hookPort > 0) sources.push(`Claude Code hooks (port ${hookPort})`) + if (sessionWatcherActive) sources.push('Claude Code session watcher') + if (copilotWatcherActive) sources.push('Copilot Chat watcher') + + stream.markdown('**Agent Flow Status**\n\n') + stream.markdown(`**Event Sources:** ${sources.length > 0 ? sources.join(', ') : 'None active'}\n\n`) + stream.markdown(`**Active Sessions:** ${sessions.length}\n`) + if (sessions.length > 0) { + for (const s of sessions) { + stream.markdown(`- ${s.label || s.id.slice(0, 8)} (${s.status})\n`) + } + } +} diff --git a/extension/src/copilot-lm-tools.ts b/extension/src/copilot-lm-tools.ts new file mode 100644 index 0000000..35c7d0c --- /dev/null +++ b/extension/src/copilot-lm-tools.ts @@ -0,0 +1,84 @@ +import * as vscode from 'vscode' +import { SessionInfo } from './protocol' +import { createLogger } from './logger' + +const log = createLogger('LMTool') + +/** + * Registers the `agentFlow_getSessionStatus` language model tool. + * + * This lets GitHub Copilot invoke the tool during chat to query + * Agent Flow session status — e.g., "what sessions are active?" + * + * Uses runtime access since the Language Model Tool API may not be + * present in all @types/vscode versions. + */ +export function registerLanguageModelTools( + _context: vscode.ExtensionContext, + deps: { + getActiveSessions: () => SessionInfo[] + getHookPort: () => number + }, +): vscode.Disposable[] { + const disposables: vscode.Disposable[] = [] + + try { + // Runtime check: vscode.lm.registerTool may not exist in older VS Code versions + const lmNs = vscode.lm as Record + if (typeof lmNs.registerTool !== 'function') { + log.debug('vscode.lm.registerTool not available in this VS Code version') + return disposables + } + + const registerTool = lmNs.registerTool as (name: string, impl: unknown) => vscode.Disposable + + const tool = registerTool('agentFlow_getSessionStatus', { + async invoke( + options: { input?: { sessionId?: string } }, + _token: vscode.CancellationToken, + ) { + const sessions = deps.getActiveSessions() + const requestedId = options.input?.sessionId + + const result = requestedId + ? sessions.find(s => s.id === requestedId || s.id.startsWith(requestedId)) + ?? { error: 'Session not found', sessionId: requestedId } + : { + hookServerPort: deps.getHookPort(), + activeSessions: sessions.length, + sessions: sessions.map(s => ({ + id: s.id, + label: s.label, + status: s.status, + startTime: s.startTime, + lastActivityTime: s.lastActivityTime, + age: Math.round((Date.now() - s.startTime) / 1000), + })), + } + + // Construct result using runtime-available classes + const ToolResult = (vscode as Record).LanguageModelToolResult as + new (parts: unknown[]) => unknown + const TextPart = (vscode as Record).LanguageModelTextPart as + new (text: string) => unknown + + if (ToolResult && TextPart) { + return new ToolResult([new TextPart(JSON.stringify(result))]) + } + // Fallback: return plain object + return { text: JSON.stringify(result) } + }, + + async prepareInvocation() { + return { invocationMessage: 'Checking Agent Flow session status...' } + }, + }) + + disposables.push(tool) + log.info('Language model tool registered: agentFlow_getSessionStatus') + } catch (err) { + log.debug('Language model tool API not available:', err) + } + + return disposables +} diff --git a/extension/src/copilot-watcher.ts b/extension/src/copilot-watcher.ts new file mode 100644 index 0000000..e27f2a1 --- /dev/null +++ b/extension/src/copilot-watcher.ts @@ -0,0 +1,272 @@ +import * as vscode from 'vscode' +import { AgentEvent, SessionInfo } from './protocol' +import { ORCHESTRATOR_NAME } from './constants' +import { createLogger } from './logger' + +const log = createLogger('CopilotWatcher') + +const COPILOT_SESSION_PREFIX = 'copilot-' +const COPILOT_AGENT_NAME = 'Copilot' + +/** + * Watches GitHub Copilot Chat activity via the VS Code Chat API. + * + * Unlike Claude Code (which writes JSONL transcripts), Copilot Chat events + * are observed through the VS Code extension API: + * - Chat response events (onDidPerformAction) + * - Language model tool invocations + * - Chat request/response lifecycle + * + * Events are translated to the same AgentEvent format used by the rest + * of the visualizer so the webview treats them identically. + */ +export class CopilotWatcher implements vscode.Disposable { + private disposables: vscode.Disposable[] = [] + private active = false + private sessionCounter = 0 + private sessions = new Map() + + private readonly _onEvent = new vscode.EventEmitter() + private readonly _onSessionDetected = new vscode.EventEmitter() + private readonly _onSessionLifecycle = new vscode.EventEmitter<{ + type: 'started' | 'ended' | 'updated' + sessionId: string + label: string + }>() + + readonly onEvent = this._onEvent.event + readonly onSessionDetected = this._onSessionDetected.event + readonly onSessionLifecycle = this._onSessionLifecycle.event + + isActive(): boolean { + return this.active + } + + getActiveSessions(): SessionInfo[] { + return Array.from(this.sessions.values()).map(s => ({ + id: s.id, + label: s.label, + status: s.completed ? 'completed' as const : 'active' as const, + startTime: s.startTime, + lastActivityTime: s.lastActivityTime, + })) + } + + start(): void { + if (this.active) return + + const enabled = vscode.workspace.getConfiguration('agentVisualizer').get('watchCopilotChat', true) + if (!enabled) { + log.info('Copilot Chat watching disabled by setting') + return + } + + // Watch for chat action events (available in VS Code 1.93+) + // Use runtime check since @types/vscode may not include this yet + try { + const chatNs = vscode.chat as Record + if (typeof chatNs.onDidPerformAction === 'function') { + const disposable = (chatNs.onDidPerformAction as vscode.Event)((action: unknown) => { + this.handleChatAction(action) + }) + this.disposables.push(disposable) + log.info('Registered chat action listener') + } + } catch (err) { + log.debug('Chat action API not available:', err) + } + + // Watch for configuration changes + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('agentVisualizer.watchCopilotChat')) { + const nowEnabled = vscode.workspace.getConfiguration('agentVisualizer').get('watchCopilotChat', true) + if (!nowEnabled && this.active) { + log.info('Copilot Chat watching disabled') + this.stop() + } + } + }), + ) + + this.active = true + log.info('Copilot Chat watcher started') + } + + stop(): void { + this.active = false + // Mark active sessions as completed + for (const [sessionId, session] of this.sessions) { + if (!session.completed) { + session.completed = true + this._onSessionLifecycle.fire({ type: 'ended', sessionId, label: session.label }) + } + } + } + + /** Create a new Copilot session and emit spawn events */ + createSession(label?: string): string { + this.sessionCounter++ + const sessionId = `${COPILOT_SESSION_PREFIX}${Date.now()}-${this.sessionCounter}` + const sessionLabel = label || `Copilot Chat #${this.sessionCounter}` + + const session: CopilotSession = { + id: sessionId, + label: sessionLabel, + startTime: Date.now(), + lastActivityTime: Date.now(), + completed: false, + toolCallCounter: 0, + } + + this.sessions.set(sessionId, session) + this._onSessionDetected.fire(sessionId) + this._onSessionLifecycle.fire({ type: 'started', sessionId, label: sessionLabel }) + + // Emit agent spawn for the Copilot orchestrator + this._onEvent.fire({ + time: 0, + type: 'agent_spawn', + payload: { name: COPILOT_AGENT_NAME, task: sessionLabel }, + sessionId, + }) + + log.info(`Session created: ${sessionId} (${sessionLabel})`) + return sessionId + } + + /** Emit a tool call start event for the given session */ + emitToolCallStart(sessionId: string, toolName: string, args?: Record): string { + const session = this.sessions.get(sessionId) + if (!session) return '' + + session.toolCallCounter++ + session.lastActivityTime = Date.now() + const toolCallId = `copilot-tool-${session.toolCallCounter}` + + this._onEvent.fire({ + time: Date.now() - session.startTime, + type: 'tool_call_start', + payload: { + agent: COPILOT_AGENT_NAME, + tool: toolName, + toolCallId, + args: args ? JSON.stringify(args).slice(0, 200) : '', + preview: toolName, + }, + sessionId, + }) + + return toolCallId + } + + /** Emit a tool call end event for the given session */ + emitToolCallEnd(sessionId: string, toolCallId: string, toolName: string, result?: string): void { + const session = this.sessions.get(sessionId) + if (!session) return + + session.lastActivityTime = Date.now() + + this._onEvent.fire({ + time: Date.now() - session.startTime, + type: 'tool_call_end', + payload: { + agent: COPILOT_AGENT_NAME, + tool: toolName, + toolCallId, + result: result?.slice(0, 200) || '', + }, + sessionId, + }) + } + + /** Emit a message event (user or assistant) */ + emitMessage(sessionId: string, role: 'user' | 'assistant', content: string): void { + const session = this.sessions.get(sessionId) + if (!session) return + + session.lastActivityTime = Date.now() + + this._onEvent.fire({ + time: Date.now() - session.startTime, + type: 'message', + payload: { + agent: COPILOT_AGENT_NAME, + role, + content: content.slice(0, 2000), + }, + sessionId, + }) + } + + /** Emit a model detection event */ + emitModelDetected(sessionId: string, model: string): void { + const session = this.sessions.get(sessionId) + if (!session) return + + this._onEvent.fire({ + time: Date.now() - session.startTime, + type: 'model_detected', + payload: { model }, + sessionId, + }) + } + + /** Mark a session as completed */ + completeSession(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session || session.completed) return + + session.completed = true + session.lastActivityTime = Date.now() + + this._onEvent.fire({ + time: Date.now() - session.startTime, + type: 'agent_complete', + payload: { name: COPILOT_AGENT_NAME, sessionEnd: true }, + sessionId, + }) + + this._onSessionLifecycle.fire({ type: 'ended', sessionId, label: session.label }) + log.info(`Session completed: ${sessionId}`) + } + + private handleChatAction(action: unknown): void { + // Chat actions indicate Copilot is doing something — create/update session + const kind = (action as Record)?.kind + log.debug('Chat action received:', kind) + } + + /** Replay session start events for a newly connected webview */ + replaySessionStart(sessionIds?: string[]): void { + for (const [sessionId, session] of this.sessions) { + if (sessionIds && !sessionIds.includes(sessionId)) continue + if (session.completed) continue + + this._onEvent.fire({ + time: 0, + type: 'agent_spawn', + payload: { name: COPILOT_AGENT_NAME, task: session.label }, + sessionId, + }) + } + } + + dispose(): void { + this.stop() + for (const d of this.disposables) d.dispose() + this.disposables = [] + this._onEvent.dispose() + this._onSessionDetected.dispose() + this._onSessionLifecycle.dispose() + } +} + +interface CopilotSession { + id: string + label: string + startTime: number + lastActivityTime: number + completed: boolean + toolCallCounter: number +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index fe9e972..540dfa9 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -3,6 +3,9 @@ import { VisualizerPanel } from './webview-provider' import { JsonlEventSource } from './event-source' import { HookServer } from './hook-server' import { SessionWatcher } from './session-watcher' +import { CopilotWatcher } from './copilot-watcher' +import { registerChatParticipant } from './copilot-chat-participant' +import { registerLanguageModelTools } from './copilot-lm-tools' import { AgentEvent, WebviewToExtensionMessage } from './protocol' import { ORCHESTRATOR_NAME, HOOK_SERVER_NOT_STARTED, @@ -34,6 +37,7 @@ function filterOrchestratorCompletion(event: AgentEvent): AgentEvent | null { let eventSource: JsonlEventSource | undefined let hookServer: HookServer | undefined let sessionWatcher: SessionWatcher | undefined +let copilotWatcher: CopilotWatcher | undefined export async function activate(context: vscode.ExtensionContext) { log.info('Extension activated') @@ -154,6 +158,71 @@ export async function activate(context: vscode.ExtensionContext) { sessionWatcher.start() + // ─── Start the Copilot Chat watcher ───────────────────────────────────── + + copilotWatcher = new CopilotWatcher() + context.subscriptions.push(copilotWatcher) + + copilotWatcher.onEvent((event) => { + const panel = VisualizerPanel.getCurrent() + if (!panel || !panel.isReady) { return } + panel.sendEvent(event) + }) + + copilotWatcher.onSessionDetected((sessionId) => { + const panel = VisualizerPanel.getCurrent() + if (panel) { + panel.setConnectionStatus('watching', `Copilot Chat session ${sessionId.slice(0, SESSION_ID_DISPLAY)}`) + } + vscode.window.setStatusBarMessage(`Agent Flow: watching Copilot session ${sessionId.slice(0, SESSION_ID_DISPLAY)}`, STATUS_MESSAGE_DURATION_MS) + }) + + copilotWatcher.onSessionLifecycle((lifecycle) => { + const panel = VisualizerPanel.getCurrent() + if (!panel) { return } + if (lifecycle.type === 'started') { + panel.postMessage({ + type: 'session-started', + session: { + id: lifecycle.sessionId, + label: lifecycle.label, + status: 'active' as const, + startTime: Date.now(), + lastActivityTime: Date.now(), + }, + }) + } else if (lifecycle.type === 'updated') { + panel.postMessage({ type: 'session-updated', sessionId: lifecycle.sessionId, label: lifecycle.label }) + } else { + panel.postMessage({ type: 'session-ended', sessionId: lifecycle.sessionId }) + } + }) + + copilotWatcher.start() + + // ─── Register Copilot Chat participant & LM tools ────────────────────── + + const allActiveSessions = () => [ + ...(sessionWatcher?.getActiveSessions() ?? []), + ...(copilotWatcher?.getActiveSessions() ?? []), + ] + + context.subscriptions.push( + registerChatParticipant(context, { + getActiveSessions: allActiveSessions, + getHookPort: () => hookServer?.getPort() ?? 0, + isSessionWatcherActive: () => sessionWatcher?.isActive() ?? false, + isCopilotWatcherActive: () => copilotWatcher?.isActive() ?? false, + copilotWatcher, + }), + ) + + const lmToolDisposables = registerLanguageModelTools(context, { + getActiveSessions: allActiveSessions, + getHookPort: () => hookServer?.getPort() ?? 0, + }) + context.subscriptions.push(...lmToolDisposables) + // ─── Commands ────────────────────────────────────────────────────────────── context.subscriptions.push( @@ -176,6 +245,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('agentVisualizer.connectToAgent', async () => { const choice = await vscode.window.showQuickPick( [ + { label: '$(copilot) Copilot Chat', description: 'Watch GitHub Copilot Chat activity', value: 'copilot' }, { label: '$(radio-tower) Claude Code Hooks', description: 'Auto-configure hooks for live streaming', value: 'hooks' }, { label: '$(file) Watch JSONL File', description: 'Watch a file for agent events', value: 'jsonl' }, { label: '$(play) Mock Data', description: 'Use built-in demo scenario', value: 'mock' }, @@ -185,7 +255,17 @@ export async function activate(context: vscode.ExtensionContext) { if (!choice) { return } - if (choice.value === 'hooks') { + if (choice.value === 'copilot') { + if (copilotWatcher && !copilotWatcher.isActive()) { + copilotWatcher.start() + } + const sessionId = copilotWatcher?.createSession() + const panel = VisualizerPanel.getCurrent() ?? VisualizerPanel.create(context.extensionUri, vscode.ViewColumn.One) + wirePanel(panel) + if (sessionId) { + panel.setConnectionStatus('watching', 'Copilot Chat') + } + } else if (choice.value === 'hooks') { await configureClaudeHooks() } else if (choice.value === 'jsonl') { const fileUri = await vscode.window.showOpenDialog({ @@ -214,6 +294,23 @@ export async function activate(context: vscode.ExtensionContext) { }), ) + context.subscriptions.push( + vscode.commands.registerCommand('agentVisualizer.watchCopilotChat', () => { + if (!copilotWatcher) { + copilotWatcher = new CopilotWatcher() + context.subscriptions.push(copilotWatcher) + } + if (!copilotWatcher.isActive()) { + copilotWatcher.start() + } + const sessionId = copilotWatcher.createSession() + const panel = VisualizerPanel.getCurrent() ?? VisualizerPanel.create(context.extensionUri, vscode.ViewColumn.One) + wirePanel(panel) + panel.setConnectionStatus('watching', 'Copilot Chat') + vscode.window.showInformationMessage('Agent Flow: Now watching Copilot Chat activity') + }), + ) + // ─── Serializer (restore panel on VS Code restart) ───────────────────────── vscode.window.registerWebviewPanelSerializer(VisualizerPanel.viewType, { @@ -252,10 +349,20 @@ function wirePanel(panel: VisualizerPanel): void { sessionWatcher.replaySessionStart(sessions.map(s => s.id)) } } - if (hookServer && hookServer.getPort() > 0) { - panel.setConnectionStatus('watching', `Hooks :${hookServer.getPort()} + session watcher`) - } else { - panel.setConnectionStatus('watching', 'Session watcher') + // Also replay any active Copilot sessions + if (copilotWatcher) { + const copilotSessions = copilotWatcher.getActiveSessions() + if (copilotSessions.length > 0) { + panel.postMessage({ type: 'session-list', sessions: copilotSessions }) + copilotWatcher.replaySessionStart(copilotSessions.map(s => s.id)) + } + } + { + const sources: string[] = [] + if (hookServer && hookServer.getPort() > 0) sources.push(`Hooks :${hookServer.getPort()}`) + if (sessionWatcher?.isActive()) sources.push('Session watcher') + if (copilotWatcher?.isActive()) sources.push('Copilot Chat') + panel.setConnectionStatus('watching', sources.join(' + ') || 'Waiting for activity') } break @@ -357,5 +464,9 @@ export function deactivate(): void { sessionWatcher.dispose() sessionWatcher = undefined } + if (copilotWatcher) { + copilotWatcher.dispose() + copilotWatcher = undefined + } } diff --git a/web/components/agent-visualizer/index.tsx b/web/components/agent-visualizer/index.tsx index b1cbf23..43e49e4 100644 --- a/web/components/agent-visualizer/index.tsx +++ b/web/components/agent-visualizer/index.tsx @@ -354,6 +354,14 @@ export function AgentVisualizer() { timelineEntries={timelineEntries} currentTime={currentTime} onClose={() => setShowTimeline(false)} + onSeek={(time) => { + seekingRef.current = true + pause() + seekToTime(time) + setZoomToFitTrigger(n => n + 1) + if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current) + resumeTimerRef.current = setTimeout(() => { resumeTimerRef.current = null; seekingRef.current = false }, TIMING.seekCompleteDelayMs) + }} /> {/* Top bar: session tabs + info/controls */} diff --git a/web/components/agent-visualizer/timeline-panel.tsx b/web/components/agent-visualizer/timeline-panel.tsx index cb940d7..4605928 100644 --- a/web/components/agent-visualizer/timeline-panel.tsx +++ b/web/components/agent-visualizer/timeline-panel.tsx @@ -9,9 +9,10 @@ interface TimelinePanelProps { timelineEntries: Map currentTime: number onClose: () => void + onSeek?: (time: number) => void } -export function TimelinePanel({ visible, timelineEntries, currentTime, onClose }: TimelinePanelProps) { +export function TimelinePanel({ visible, timelineEntries, currentTime, onClose, onSeek }: TimelinePanelProps) { const entries = Array.from(timelineEntries.values()) .sort((a, b) => a.startTime - b.startTime) @@ -58,7 +59,15 @@ export function TimelinePanel({ visible, timelineEntries, currentTime, onClose } {/* Time markers header */}
-
+
{ + const rect = e.currentTarget.getBoundingClientRect() + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(minTime + fraction * timeSpan) + } : undefined} + > {markers.map(t => { const left = ((t - minTime) / timeSpan) * 100 return ( @@ -91,7 +100,15 @@ export function TimelinePanel({ visible, timelineEntries, currentTime, onClose }
{/* Blocks bar */} -
+
{ + const rect = e.currentTarget.getBoundingClientRect() + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(minTime + fraction * timeSpan) + } : undefined} + > {/* Background track */}