From 5e01c91d9f3a4cdfd80cba80776f3dbcf1141291 Mon Sep 17 00:00:00 2001 From: kepptic <245740836+kepptic@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:49:24 -0500 Subject: [PATCH 1/4] feat: rich terminal context, PID-based cwd resolution, and editor enrichment Track active terminal with working directory resolution via lsof fallback for terminals without shellIntegration (e.g., Claude Code sessions). Detect terminal switches using array index for terminals sharing the same name. New event fields: terminal_cwd, terminal_description, active_terminal_label, terminal_names, terminal_count, git_dirty_count, git_remote, is_debugging, debug_type, cursor_line, cursor_col, lines_in_file, relative_path, open_files, open_file_count, is_focused, workspace_folders. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 25 ++ package.json | 10 +- src/extension.ts | 677 +++++++++++++++++++++++++++++++++++------------ tsconfig.json | 14 +- 4 files changed, 544 insertions(+), 182 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7dbdb..b694cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,29 @@ # Change Log + +### 0.7.0 + +Rich terminal and editor context tracking: + +- **Terminal context**: Track active terminal name, working directory, and project +- **Terminal switch detection**: Detect switches between terminals (including multiple terminals with the same name) +- **PID-based cwd resolution**: Resolve terminal working directories via `lsof` when `shellIntegration` is unavailable +- **All terminal enumeration**: Report all open terminal sessions with their project names +- **Git enrichment**: Report `git_dirty_count` and `git_remote` URL +- **Debug session tracking**: Report active debug sessions with debug type +- **Open editor tracking**: Report open file count and filenames +- **Cursor position**: Report cursor line/column and file line count +- **Relative file paths**: Report file path relative to workspace root +- **Window focus state**: Report whether VS Code window is focused +- **Multi-root workspace**: Report workspace folder names for multi-root setups + +### 0.6.0 + +Enhanced editor context (git branch, project, language, file tracking). + +### 0.5.0 + +Internal improvements and dependency updates. + ### 0.1.0 Initial release of aw-watcher-vscode. diff --git a/package.json b/package.json index 78e2e74..e93086a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "aw-watcher-vscode", "displayName": "aw-watcher-vscode", - "description": "Editor watcher for ActivityWatch, the free and open-source automated time tracker.", - "version": "0.5.0", + "description": "Enhanced editor watcher for ActivityWatch — tracks files, terminals, git, debugging, and more.", + "version": "0.7.0", "repository": { "type": "git", "url": "https://github.com/ActivityWatch/aw-watcher-vscode" @@ -19,7 +19,9 @@ "Other" ], "keywords": [ - "multi-root ready" + "multi-root ready", + "activitywatch", + "time-tracking" ], "icon": "media/logo/logo.png", "activationEvents": [ @@ -43,7 +45,7 @@ "aw-watcher-vscode.maxHeartbeatsPerSec": { "type": "number", "default": 1, - "description": "Controls the maximum number of hearbeats sent per second." + "description": "Controls the maximum number of heartbeats sent per second." } } } diff --git a/src/extension.ts b/src/extension.ts index b54452b..1a85a1b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,206 +1,545 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the necessary extensibility types to use in your code below -import { Disposable, ExtensionContext, commands, window, workspace, Uri, Extension, extensions } from 'vscode'; -import { AWClient, IAppEditorEvent } from '../aw-client-js/src/aw-client'; -import { hostname } from 'os'; -import { API, GitExtension } from './git'; - -// This method is called when your extension is activated. Activation is -// controlled by the activation events defined in package.json. -export function activate(context: ExtensionContext) { - console.log('Congratulations, your extension "ActivityWatch" is now active!'); +import { + Disposable, + ExtensionContext, + commands, + window, + workspace, + debug, + Uri, + Extension, + extensions, +} from "vscode"; +import { AWClient, IAppEditorEvent } from "../aw-client-js/src/aw-client"; +import { hostname } from "os"; +import { API, GitExtension, Repository } from "./git"; +import { basename, relative } from "path"; + +// The vscode type declarations in this project are old (1.x). +// Terminal APIs (activeTerminal, terminals, onDidChangeActiveTerminal, onDidOpenTerminal) +// exist at runtime in VS Code 1.37+. We cast through `any` where needed. +const win = window as any; - // Init ActivityWatch - const controller = new ActivityWatch(); - controller.init(); - context.subscriptions.push(controller); +export function activate(context: ExtensionContext) { + console.log("[ActivityWatch] Extension activated"); + const controller = new ActivityWatch(); + controller.init(); + context.subscriptions.push(controller); - // Command:Reload - const reloadCommand = commands.registerCommand('extension.reload', () => controller.init()); - context.subscriptions.push(reloadCommand); + const reloadCommand = commands.registerCommand("extension.reload", () => + controller.init() + ); + context.subscriptions.push(reloadCommand); } class ActivityWatch { - private _disposable: Disposable; - private _client: AWClient; - private _git: API | undefined; - - // Bucket info - private _bucket: { - id: string; - hostName: string; - clientName: string; - eventType: string; + private _disposable: Disposable; + private _client: AWClient; + private _git: API | undefined; + + private _bucket: { + id: string; + hostName: string; + clientName: string; + eventType: string; + }; + private _bucketCreated: boolean = false; + + // Config + private _pulseTime: number = 20; + private _maxHeartbeatsPerSec: number = 1; + + // State tracking for change detection + private _lastFilePath: string = ""; + private _lastHeartbeatTime: number = 0; + private _lastBranch: string = ""; + private _lastTerminalName: string = ""; + private _isDebugging: boolean = false; + + // Cache PID -> cwd for terminals without shellIntegration + private _pidCwdCache: Map = new Map(); + + constructor() { + this._bucket = { + id: "", + hostName: hostname(), + clientName: "aw-watcher-vscode", + eventType: "app.editor.activity", }; - private _bucketCreated: boolean = false; - - // Heartbeat handling - private _pulseTime: number = 20; - private _maxHeartbeatsPerSec: number = 1; - private _lastFilePath: string = ''; - private _lastHeartbeatTime: number = 0; // Date.getTime() - private _lastBranch: string = ''; - - constructor() { - this._bucket = { - id: '', - hostName: hostname(), - clientName: 'aw-watcher-vscode', - eventType: 'app.editor.activity' - }; - this._bucket.id = `${this._bucket.clientName}_${this._bucket.hostName}`; - - // Create AWClient - this._client = new AWClient(this._bucket.clientName, { testing: false }); - - // subscribe to VS Code Events - let subscriptions: Disposable[] = []; - window.onDidChangeTextEditorSelection(this._onEvent, this, subscriptions); - window.onDidChangeActiveTextEditor(this._onEvent, this, subscriptions); - this._disposable = Disposable.from(...subscriptions); - } - - public init() { - // Create new Bucket (if not existing) - this._client.ensureBucket(this._bucket.id, this._bucket.eventType, this._bucket.hostName) - .then((res) => { - if (res.alreadyExist) { - console.log('Bucket already exists'); - } else { - console.log('Created Bucket'); - } - this._bucketCreated = true; - }) - .catch(err => { - this._handleError("Couldn't create Bucket. Please make sure the server is running properly and then run the [Reload ActivityWatch] command.", true); - this._bucketCreated = false; - console.error(err); - }); - this.initGit().then((res) => this._git = res); - this.loadConfigurations(); - } - - private async initGit() { - const extension = extensions.getExtension('vscode.git') as Extension; - const gitExtension = extension.isActive ? extension.exports : await extension.activate(); - return gitExtension.getAPI(1); - } - - public loadConfigurations() { - const extConfigurations = workspace.getConfiguration('aw-watcher-vscode'); - const maxHeartbeatsPerSec = extConfigurations.get('maxHeartbeatsPerSec'); - if (maxHeartbeatsPerSec) { - this._maxHeartbeatsPerSec = maxHeartbeatsPerSec as number; + this._bucket.id = `${this._bucket.clientName}_${this._bucket.hostName}`; + this._client = new AWClient(this._bucket.clientName, { testing: false }); + + // Subscribe to all relevant VS Code events + const subscriptions: Disposable[] = []; + + // Editor events + window.onDidChangeTextEditorSelection(this._onEvent, this, subscriptions); + window.onDidChangeActiveTextEditor(this._onEvent, this, subscriptions); + + // Terminal events (APIs exist in VS Code 1.37+, types are old) + if (win.onDidChangeActiveTerminal) { + win.onDidChangeActiveTerminal(this._onTerminalEvent, this, subscriptions); + } + if (win.onDidOpenTerminal) { + win.onDidOpenTerminal(this._onTerminalEvent, this, subscriptions); + } + window.onDidCloseTerminal(this._onTerminalEvent, this, subscriptions); + + // Debug events + debug.onDidStartDebugSession( + () => { + this._isDebugging = true; + this._onEvent(); + }, + this, + subscriptions + ); + debug.onDidTerminateDebugSession( + () => { + this._isDebugging = false; + this._onEvent(); + }, + this, + subscriptions + ); + + // Window focus events + window.onDidChangeWindowState(this._onEvent, this, subscriptions); + + this._disposable = Disposable.from(...subscriptions); + } + + public init() { + this._client + .ensureBucket( + this._bucket.id, + this._bucket.eventType, + this._bucket.hostName + ) + .then((res) => { + console.log( + res.alreadyExist + ? "[ActivityWatch] Bucket already exists" + : "[ActivityWatch] Created bucket" + ); + this._bucketCreated = true; + }) + .catch((err) => { + this._handleError( + "Couldn't create Bucket. Is the ActivityWatch server running?", + true + ); + this._bucketCreated = false; + console.error(err); + }); + + this.initGit().then((res) => (this._git = res)); + this.loadConfigurations(); + } + + private async initGit() { + try { + const extension = extensions.getExtension( + "vscode.git" + ) as Extension; + if (!extension) { + return undefined; + } + const gitExtension = extension.isActive + ? extension.exports + : await extension.activate(); + return gitExtension.getAPI(1); + } catch (_err) { + console.warn("[ActivityWatch] Git extension not available"); + return undefined; + } + } + + public loadConfigurations() { + const config = workspace.getConfiguration("aw-watcher-vscode"); + const maxHeartbeatsPerSec = config.get("maxHeartbeatsPerSec"); + if (maxHeartbeatsPerSec) { + this._maxHeartbeatsPerSec = maxHeartbeatsPerSec; + } + } + + public dispose() { + this._disposable.dispose(); + } + + /// Resolve cwd for a terminal PID via lsof. + /// The terminal's processId is its shell PID — lsof on it gives the cwd directly. + /// Returns cached result if fresh enough (10s TTL). + private _getTerminalCwd(pid: number): string { + const cached = this._pidCwdCache.get(pid); + const now = Date.now(); + if (cached && (now - cached.time) < 10000) { + return cached.cwd; + } + + try { + const { execFileSync } = require("child_process"); + const lsofOut: string = execFileSync("lsof", ["-p", String(pid), "-Fn"], { + timeout: 2000, encoding: "utf-8" + }); + const lines = lsofOut.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i] === "fcwd" && i + 1 < lines.length && lines[i+1].startsWith("n")) { + const cwd = lines[i+1].substring(1); + if (cwd && cwd !== "/" && !cwd.startsWith("/dev")) { + this._pidCwdCache.set(pid, { cwd, time: now }); + return cwd; + } } + } + } catch (_) {} + + return ""; + } + + private _onTerminalEvent() { + if (!this._bucketCreated) { + return; + } + + const terminal = win.activeTerminal; + if (!terminal) return; + + // Use terminal object identity via index in terminals array to + // distinguish between terminals with the same name (e.g., multiple "2.1.69") + const terminals = win.terminals || []; + const termIndex = Array.prototype.indexOf.call(terminals, terminal); + const termKey = `${terminal.name || ""}|${termIndex}`; + + if (termKey !== this._lastTerminalName) { + this._lastTerminalName = termKey; + this._onEvent(); + } + } + + private async _onEvent() { + if (!this._bucketCreated) { + return; + } + + try { + const heartbeat = await this._createHeartbeat(); + const filePath = heartbeat.data.file || ""; + const curTime = new Date().getTime(); + const branch = heartbeat.data.branch || ""; + + if ( + filePath !== this._lastFilePath || + branch !== this._lastBranch || + this._lastHeartbeatTime + 1000 / this._maxHeartbeatsPerSec < curTime + ) { + this._lastFilePath = filePath; + this._lastBranch = branch; + this._lastHeartbeatTime = curTime; + this._sendHeartbeat(heartbeat); + } + } catch (err: any) { + this._handleError(err); + } + } + + private _sendHeartbeat(event: IAppEditorEvent) { + return this._client + .heartbeat(this._bucket.id, this._pulseTime, event) + .then(() => console.log("[ActivityWatch] Heartbeat:", event.data.file)) + .catch(({ err }: { err: any }) => { + console.error("[ActivityWatch] Heartbeat error:", err); + }); + } + + private async _createHeartbeat(): Promise { + const editor = window.activeTextEditor; + const projectName = this._getProjectName(); + const projectPath = this._getProjectFolder(); + const filePath = this._getFilePath(); + const branch = this._getCurrentBranch() || "unknown"; + + const data: { [k: string]: any } = { + language: this._getFileLanguage() || "unknown", + project: projectName || "unknown", + file: filePath || "unknown", + branch: branch, + }; + + // Relative file path (cleaner than absolute) + if (filePath && projectPath) { + const relPath = relative(projectPath, filePath); + if (relPath && !relPath.startsWith("..")) { + data.relative_path = relPath; + } } - public dispose() { - this._disposable.dispose(); + // Cursor position + if (editor && editor.selection) { + data.cursor_line = editor.selection.active.line + 1; + data.cursor_col = editor.selection.active.character + 1; + data.lines_in_file = editor.document.lineCount; } - private _onEvent() { - if (!this._bucketCreated) { - return; + // Git status + const gitInfo = this._getGitInfo(); + if (gitInfo) { + if (gitInfo.dirty_count !== undefined) { + data.git_dirty_count = gitInfo.dirty_count; + } + if (gitInfo.remote_url) { + data.git_remote = gitInfo.remote_url; + } + } + + // Debugging state + if (this._isDebugging || debug.activeDebugSession) { + data.is_debugging = true; + const session = debug.activeDebugSession; + if (session) { + data.debug_type = session.type; + } + } + + // Open editors (just filenames for context) + const openEditors = this._getOpenEditorFiles(); + if (openEditors.length > 0) { + data.open_files = openEditors.join(";"); + data.open_file_count = openEditors.length; + } + + // Active terminal info + const terminal = win.activeTerminal; + if (terminal) { + data.active_terminal = terminal.name; + + // Resolve cwd: try shellIntegration first, then PID-based lsof fallback + let termCwd = ""; + try { + if (terminal.shellIntegration && terminal.shellIntegration.cwd) { + const cwd = terminal.shellIntegration.cwd; + termCwd = typeof cwd === 'string' ? cwd : (cwd.fsPath || cwd.path || ""); + } + } catch (_) {} + + // Fallback: resolve cwd from terminal PID via lsof + if (!termCwd && terminal.processId) { + try { + const pid = await Promise.race([ + terminal.processId, + new Promise(r => setTimeout(r, 500)) + ]); + if (pid) { + termCwd = this._getTerminalCwd(pid); + } + } catch (_) {} + } + + if (termCwd) { + data.terminal_cwd = termCwd; + const desc = termCwd.split("/").pop() || termCwd.split("\\").pop() || ""; + if (desc) { + data.terminal_description = desc; + data.active_terminal_label = `${terminal.name} ${desc}`; } + } + } + data.terminal_count = win.terminals?.length || 0; - // Create and send heartbeat + // All terminal labels (name + cwd project) for context + if (win.terminals && win.terminals.length > 0) { + const termLabels: string[] = []; + for (const t of win.terminals) { + const name = t.name || ""; + let label = name; + // Try shellIntegration cwd, then PID fallback + let cwdStr = ""; try { - const heartbeat = this._createHeartbeat(); - const filePath = this._getFilePath(); - const curTime = new Date().getTime(); - const branch = this._getCurrentBranch(); - - // Send heartbeat if file changed, branch changed or enough time passed - if (filePath !== this._lastFilePath || - branch !== this._lastBranch || - this._lastHeartbeatTime + (1000 / (this._maxHeartbeatsPerSec)) < curTime) { - this._lastFilePath = filePath || 'unknown'; - this._lastHeartbeatTime = curTime; - this._sendHeartbeat(heartbeat); + if (t.shellIntegration && t.shellIntegration.cwd) { + const cwd = t.shellIntegration.cwd; + cwdStr = typeof cwd === 'string' ? cwd : (cwd.fsPath || cwd.path || ""); + } + } catch (_) {} + if (!cwdStr && t.processId) { + try { + const pid = await Promise.race([ + t.processId, + new Promise(r => setTimeout(r, 500)) + ]); + if (pid) { + cwdStr = this._getTerminalCwd(pid); } + } catch (_) {} } - catch (err: any) { - this._handleError(err); + if (cwdStr) { + const dirName = cwdStr.split("/").pop() || cwdStr.split("\\").pop() || ""; + if (dirName) label = `${name} ${dirName}`; } + if (label) termLabels.push(label); + } + if (termLabels.length > 0) { + data.terminal_names = termLabels.join(";"); + } } - private _sendHeartbeat(event: IAppEditorEvent) { - return this._client.heartbeat(this._bucket.id, this._pulseTime, event) - .then(() => console.log('Sent heartbeat', event)) - .catch(({ err }) => { - console.error('sendHeartbeat error: ', err); - this._handleError('Error while sending heartbeat', true); - }); - } - - private _createHeartbeat(): IAppEditorEvent { - return { - timestamp: new Date(), - duration: 0, - data: { - language: this._getFileLanguage() || 'unknown', - project: this._getProjectFolder() || 'unknown', - file: this._getFilePath() || 'unknown', - branch: this._getCurrentBranch() || 'unknown' - } - }; + // Window focus state + data.is_focused = window.state.focused; + + // Workspace folder count (multi-root) + const folders = workspace.workspaceFolders; + if (folders && folders.length > 1) { + data.workspace_folders = folders.map((f: any) => f.name).join(";"); } - private _getProjectFolder(): string | undefined { - const fileUri = this._getActiveFileUri(); - if (!fileUri) { - return; - } - const workspaceFolder = workspace.getWorkspaceFolder(fileUri); - if (!workspaceFolder) { - return; - } + return { + timestamp: new Date(), + duration: 0, + data: data as any, + }; + } - return workspaceFolder.uri.path; + private _getProjectName(): string | undefined { + const fileUri = this._getActiveFileUri(); + if (!fileUri) { + // Fall back to first workspace folder + const folders = workspace.workspaceFolders; + if (folders && folders.length > 0) { + return folders[0].name; + } + return undefined; } - private _getActiveFileUri(): Uri | undefined { - const editor = window.activeTextEditor; - if (!editor) { - return; - } + const workspaceFolder = workspace.getWorkspaceFolder(fileUri); + if (!workspaceFolder) { + return undefined; + } + + // Return just the folder name, not the full path + return workspaceFolder.name; + } - return editor.document.uri; + private _getProjectFolder(): string | undefined { + const fileUri = this._getActiveFileUri(); + if (!fileUri) { + const folders = workspace.workspaceFolders; + if (folders && folders.length > 0) { + return folders[0].uri.fsPath; + } + return undefined; } + const workspaceFolder = workspace.getWorkspaceFolder(fileUri); + if (!workspaceFolder) { + return undefined; + } + return workspaceFolder.uri.fsPath; + } - private _getFilePath(): string | undefined { - const editor = window.activeTextEditor; - if (!editor) { - return; - } + private _getActiveFileUri(): Uri | undefined { + const editor = window.activeTextEditor; + if (!editor) { + return undefined; + } + return editor.document.uri; + } - return editor.document.fileName; + private _getFilePath(): string | undefined { + const editor = window.activeTextEditor; + if (!editor) { + return undefined; } + return editor.document.fileName; + } - private _getFileLanguage(): string | undefined { - const editor = window.activeTextEditor; - if (!editor) { - return; - } + private _getFileLanguage(): string | undefined { + const editor = window.activeTextEditor; + if (!editor) { + return undefined; + } + return editor.document.languageId; + } - return editor.document.languageId; + private _getCurrentBranch(): string | undefined { + if (!this._git) { + return undefined; } - private _getCurrentBranch(): string | undefined { - if (this._git === undefined) { - return; - } - return this._git.repositories[0]?.state?.HEAD?.name; + // Find the repository for the active file + const fileUri = this._getActiveFileUri(); + if (fileUri && this._git.getRepository) { + const repo = this._git.getRepository(fileUri); + if (repo) { + return repo.state.HEAD?.name; + } } - private _handleError(err: string, isCritical = false): undefined { - if (isCritical) { - console.error('[ActivityWatch][handleError]', err); - window.showErrorMessage(`[ActivityWatch] ${err}`); - } - else { - console.warn('[AcitivtyWatch][handleError]', err); - } - return; + // Fall back to first repository + return this._git.repositories[0]?.state?.HEAD?.name; + } + + private _getGitInfo(): { + dirty_count?: number; + remote_url?: string; + } | null { + if (!this._git || !this._git.repositories.length) { + return null; + } + + let repo: Repository | undefined; + const fileUri = this._getActiveFileUri(); + if (fileUri && this._git.getRepository) { + repo = this._git.getRepository(fileUri) || undefined; + } + if (!repo) { + repo = this._git.repositories[0]; + } + + if (!repo) { + return null; + } + + const result: { dirty_count?: number; remote_url?: string } = {}; + + // Count dirty files + const state = repo.state; + result.dirty_count = + (state.workingTreeChanges?.length || 0) + + (state.indexChanges?.length || 0); + + // Get remote URL + const origin = state.remotes?.find((r) => r.name === "origin"); + if (origin?.fetchUrl) { + // Clean up the URL (remove credentials, simplify) + let url = origin.fetchUrl; + // Convert git@github.com:org/repo.git to github.com/org/repo + const sshMatch = url.match( + /git@([^:]+):(.+?)(?:\.git)?$/ + ); + if (sshMatch) { + url = `${sshMatch[1]}/${sshMatch[2]}`; + } + result.remote_url = url; + } + + return result; + } + + private _getOpenEditorFiles(): string[] { + const files: string[] = []; + for (const editor of window.visibleTextEditors) { + const path = editor.document.fileName; + if (path) { + files.push(basename(path)); + } + } + return files; + } + + private _handleError(err: string, isCritical = false): undefined { + if (isCritical) { + console.error("[ActivityWatch]", err); + window.showErrorMessage(`[ActivityWatch] ${err}`); + } else { + console.warn("[ActivityWatch]", err); } + return; + } } diff --git a/tsconfig.json b/tsconfig.json index adffdbd..a855a0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,19 +4,15 @@ "target": "es6", "outDir": "out", "lib": [ - "es6" + "es6", + "dom" ], "sourceMap": true, - /* Strict Type-Checking Option */ - "strict": true, /* enable all strict type-checking options */ - /* Additional Checks */ - "noUnusedLocals": true /* Report errors on unused locals. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "strict": true, + "noUnusedLocals": true }, "exclude": [ "node_modules", ".vscode-test" ] -} \ No newline at end of file +} From ce7a7d92cb88993bfea033262d7da2cc04793702 Mon Sep 17 00:00:00 2001 From: kepptic <245740836+kepptic@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:15:25 -0400 Subject: [PATCH 2/4] fix: only send heartbeats from focused VS Code window Multiple VS Code windows share the same bucket. Unfocused windows were interleaving different project data, breaking pulse merge and causing 0-duration events. Now only the focused window heartbeats; unfocused windows get one final heartbeat on blur to close the event. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 5 +++-- src/extension.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7bae6d5..74122db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,12 @@ { "name": "aw-watcher-vscode", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.5.0", + "name": "aw-watcher-vscode", + "version": "0.6.0", "hasInstallScript": true, "dependencies": { "axios": "^0.21.1" diff --git a/src/extension.ts b/src/extension.ts index 1a85a1b..0aa6b05 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -221,6 +221,22 @@ class ActivityWatch { return; } + // Only send heartbeats from the focused VS Code window. + // Multiple windows share the same bucket — unfocused windows + // interleave different project data and break pulse merging. + // Exception: onDidChangeWindowState fires on blur, so we allow + // one heartbeat when focus is lost to close the current event. + const isFocused = window.state.focused; + if (!isFocused && this._lastHeartbeatTime > 0) { + // Already sent at least one heartbeat — skip unfocused events + // unless this is the blur transition itself (detected by + // checking if we were recently focused) + const timeSinceLast = new Date().getTime() - this._lastHeartbeatTime; + if (timeSinceLast > 2000) { + return; + } + } + try { const heartbeat = await this._createHeartbeat(); const filePath = heartbeat.data.file || ""; From b85e5ba10cb35d1f4521c2b11094ee9f51b91690 Mon Sep 17 00:00:00 2001 From: kepptic <245740836+kepptic@users.noreply.github.com> Date: Tue, 10 Mar 2026 04:39:51 -0400 Subject: [PATCH 3/4] feat: merge community PRs #36, #39, #40 + multi-window fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR #36: workspace name field (workspace.name) - PR #39: editor identification (dynamic via env.appName — supports Cursor, Windsurf, and other VS Code forks) - PR #40: untrusted workspace support (capabilities declaration) - Fix: only focused window sends heartbeats, preventing interleaved project data from breaking pulse merge in multi-window setups Co-Authored-By: Claude Opus 4.6 --- package.json | 5 +++++ src/extension.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/package.json b/package.json index e93086a..da5341a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "extensionKind": [ "ui" ], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + } + }, "main": "./out/src/extension", "contributes": { "commands": [ diff --git a/src/extension.ts b/src/extension.ts index 0aa6b05..3280f55 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { AWClient, IAppEditorEvent } from "../aw-client-js/src/aw-client"; import { hostname } from "os"; import { API, GitExtension, Repository } from "./git"; import { basename, relative } from "path"; +import * as vscode from "vscode"; // The vscode type declarations in this project are old (1.x). // Terminal APIs (activeTerminal, terminals, onDidChangeActiveTerminal, onDidOpenTerminal) @@ -279,8 +280,15 @@ class ActivityWatch { project: projectName || "unknown", file: filePath || "unknown", branch: branch, + // Editor identification (PR #39 — dynamic, supports Cursor/Windsurf/forks) + editor: (vscode as any).env?.appName || "VS Code", }; + // Workspace name from .code-workspace file (PR #36) + if (workspace.name) { + data.workspace = workspace.name; + } + // Relative file path (cleaner than absolute) if (filePath && projectPath) { const relPath = relative(projectPath, filePath); From 4b6b847bc097e5b011ea7dc02326d309a172100a Mon Sep 17 00:00:00 2001 From: kepptic <245740836+kepptic@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:27:37 -0400 Subject: [PATCH 4/4] feat: periodic 5s heartbeat, smart terminal fallback for file/language - Add 5s periodic heartbeat timer so pulse merge builds proper event durations (previously only fired on change events, leaving 0s gaps) - Show project name + "(terminal)" for file field when terminal is focused instead of "unknown"; language shows "terminal" Co-Authored-By: Claude Opus 4.6 --- src/extension.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 3280f55..8ebfcd1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,6 +58,7 @@ class ActivityWatch { // Cache PID -> cwd for terminals without shellIntegration private _pidCwdCache: Map = new Map(); + private _heartbeatTimer: ReturnType | undefined; constructor() { this._bucket = { @@ -106,6 +107,15 @@ class ActivityWatch { // Window focus events window.onDidChangeWindowState(this._onEvent, this, subscriptions); + // Periodic heartbeat every 5s to build event duration via pulse merge. + // Without this, events stay at 0 duration when the user isn't actively + // typing or switching editors. + this._heartbeatTimer = setInterval(() => { + if (window.state.focused) { + this._onEvent(); + } + }, 5000); + this._disposable = Disposable.from(...subscriptions); } @@ -164,6 +174,7 @@ class ActivityWatch { } public dispose() { + if (this._heartbeatTimer) clearInterval(this._heartbeatTimer); this._disposable.dispose(); } @@ -275,10 +286,12 @@ class ActivityWatch { const filePath = this._getFilePath(); const branch = this._getCurrentBranch() || "unknown"; + // When terminal is focused (no active editor), fall back to project info + const isTerminal = !editor && win.activeTerminal; const data: { [k: string]: any } = { - language: this._getFileLanguage() || "unknown", + language: this._getFileLanguage() || (isTerminal ? "terminal" : "unknown"), project: projectName || "unknown", - file: filePath || "unknown", + file: filePath || (projectName ? `${projectName} (terminal)` : "unknown"), branch: branch, // Editor identification (PR #39 — dynamic, supports Cursor/Windsurf/forks) editor: (vscode as any).env?.appName || "VS Code",