diff --git a/CLAUDE-IPC.md b/CLAUDE-IPC.md index 05ebf818c..1e9c9334b 100644 --- a/CLAUDE-IPC.md +++ b/CLAUDE-IPC.md @@ -78,6 +78,10 @@ window.maestro.history = { - `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason +## Integrations + +- `wakatime` - WakaTime CLI management: checkCli, validateApiKey + ## Utilities - `fonts` - Font detection diff --git a/docs/configuration.md b/docs/configuration.md index f2df6a386..ce9d91a55 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,7 @@ Settings are organized into tabs: | **Notifications** | OS notifications, custom command notifications, toast notification duration | | **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), and [OpenSpec](./openspec-commands) prompts | | **SSH Hosts** | Configure remote hosts for [SSH agent execution](./ssh-remote-execution) | +| **WakaTime** *(in General tab)* | WakaTime integration toggle, API key, detailed file tracking | ## Conductor Profile @@ -263,6 +264,51 @@ Sleep prevention on Linux uses standard freedesktop.org interfaces: On unsupported Linux configurations, the feature silently does nothing — your system will sleep normally according to its power settings. +## WakaTime Integration + +Maestro integrates with [WakaTime](https://wakatime.com) to track coding activity across your AI sessions. The WakaTime CLI is auto-installed when you enable the integration. + +**To enable:** + +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) → **General** tab +2. Toggle **Enable WakaTime tracking** on +3. Enter your API key (get it from [wakatime.com/settings/api-key](https://wakatime.com/settings/api-key)) + +### What Gets Tracked + +By default, Maestro sends **app-level heartbeats** — WakaTime sees time spent in Maestro as a single project entry with language detected from your project's manifest files (e.g., `tsconfig.json` → TypeScript). + +### Detailed File Tracking + +Enable **Detailed file tracking** to send per-file heartbeats for write operations. When an agent writes or edits a file, Maestro sends that file path to WakaTime with: + +- The file's language (detected from extension) +- A write flag indicating the file was modified +- Project name and branch + +File paths (not file content) are sent to WakaTime's servers. This setting defaults to off and requires two explicit opt-ins (WakaTime enabled + detailed tracking enabled). + +### Supported Tools + +File heartbeats are generated for write operations across all supported agents: + +| Agent | Tracked Tools | +|-------|--------------| +| Claude Code | Write, Edit, NotebookEdit | +| Codex | write_to_file, str_replace_based_edit_tool, create_file | +| OpenCode | write, patch | + +Read operations and shell commands are excluded to avoid inflating tracked time. + +### Activity Categories + +Maestro assigns WakaTime categories based on how the session was initiated: + +- **Interactive sessions** (user-driven) are tracked as `building` +- **Auto Run / batch sessions** are tracked as `ai coding` + +This lets you distinguish time you spent actively directing agents from time the AI worked autonomously on your WakaTime dashboard. + ## Storage Location Settings are stored in: diff --git a/docs/features.md b/docs/features.md index 95c5a11d9..8c556e17f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -32,6 +32,7 @@ icon: sparkles - 🏷️ **[Automatic Tab Naming](./general-usage#automatic-tab-naming)** - Tabs are automatically named based on your first message. No more "New Session" clutter — each tab gets a descriptive, relevant name. - 🔔 **Custom Notifications** - Execute any command when agents complete tasks, perfect for audio alerts, logging, or integration with your notification stack. - 🎨 **[Beautiful Themes](https://github.com/RunMaestro/Maestro/blob/main/THEMES.md)** - 17 built-in themes across dark (Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark), light (GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light), and vibe (Pedurple, Maestro's Choice, Dre Synth, InQuest) categories, plus a fully customizable theme builder. +- ⏱️ **[WakaTime Integration](./configuration#wakatime-integration)** - Automatic time tracking via WakaTime with optional per-file write activity tracking across all supported agents. - 💰 **Cost Tracking** - Real-time token usage and cost tracking per session and globally. - 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. - 🎬 **[Director's Notes](./director-notes)** - Bird's-eye view of all agent activity in a unified timeline. Aggregate history from every agent, search and filter entries, and generate AI-powered synopses of recent work. Access via `Cmd+Shift+O` / `Ctrl+Shift+O`. _(Encore Feature — enable in Settings > Encore Features)_ diff --git a/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts index 525f5e35e..6e90795bc 100644 --- a/src/__tests__/main/wakatime-manager.test.ts +++ b/src/__tests__/main/wakatime-manager.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WakaTimeManager } from '../../main/wakatime-manager'; +import { + WakaTimeManager, + detectLanguageFromPath, + WRITE_TOOL_NAMES, + extractFilePathFromToolExecution, +} from '../../main/wakatime-manager'; // Mock electron vi.mock('electron', () => ({ @@ -332,7 +337,7 @@ describe('WakaTimeManager', () => { '--plugin', 'maestro/1.0.0 maestro-wakatime/1.0.0', '--category', - 'ai coding', + 'building', ]); }); @@ -422,7 +427,7 @@ describe('WakaTimeManager', () => { ); }); - it('should send heartbeat with correct arguments', async () => { + it('should send heartbeat with building category by default', async () => { // First call to detectCli vi.mocked(execFileNoThrow).mockResolvedValueOnce({ exitCode: 0, @@ -450,7 +455,7 @@ describe('WakaTimeManager', () => { '--plugin', 'maestro/1.0.0 maestro-wakatime/1.0.0', '--category', - 'ai coding', + 'building', ]); expect(logger.debug).toHaveBeenCalledWith( expect.stringContaining('Heartbeat sent for session session-1'), @@ -458,6 +463,32 @@ describe('WakaTimeManager', () => { ); }); + it('should send ai coding category for auto source', async () => { + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'wakatime-cli 1.73.1\n', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await manager.sendHeartbeat('session-1', 'My Project', undefined, 'auto'); + + expect(execFileNoThrow).toHaveBeenCalledWith( + 'wakatime-cli', + expect.arrayContaining(['--category', 'ai coding']) + ); + }); + + it('should send building category for user source', async () => { + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'wakatime-cli 1.73.1\n', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await manager.sendHeartbeat('session-1', 'My Project', undefined, 'user'); + + expect(execFileNoThrow).toHaveBeenCalledWith( + 'wakatime-cli', + expect.arrayContaining(['--category', 'building']) + ); + }); + it('should log warning on heartbeat failure', async () => { vi.mocked(execFileNoThrow) .mockResolvedValueOnce({ exitCode: 0, stdout: 'wakatime-cli 1.73.1\n', stderr: '' }) @@ -673,4 +704,580 @@ describe('WakaTimeManager', () => { expect(execFileNoThrow).toHaveBeenCalledTimes(3); }); }); + + describe('WRITE_TOOL_NAMES', () => { + it('should contain all expected write tool names', () => { + const expected = [ + 'Write', + 'Edit', + 'write_to_file', + 'str_replace_based_edit_tool', + 'create_file', + 'write', + 'patch', + 'NotebookEdit', + ]; + for (const name of expected) { + expect(WRITE_TOOL_NAMES.has(name)).toBe(true); + } + expect(WRITE_TOOL_NAMES.size).toBe(expected.length); + }); + + it('should not contain non-write tool names', () => { + expect(WRITE_TOOL_NAMES.has('Read')).toBe(false); + expect(WRITE_TOOL_NAMES.has('search')).toBe(false); + expect(WRITE_TOOL_NAMES.has('Bash')).toBe(false); + }); + }); + + describe('extractFilePathFromToolExecution', () => { + it('should extract file_path from a Write tool execution', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: { input: { file_path: '/project/src/index.ts' } }, + timestamp: Date.now(), + }); + expect(result).toBe('/project/src/index.ts'); + }); + + it('should extract path from a Codex write_to_file tool execution', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'write_to_file', + state: { input: { path: '/project/src/main.py' } }, + timestamp: Date.now(), + }); + expect(result).toBe('/project/src/main.py'); + }); + + it('should prefer file_path over path when both are present', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Edit', + state: { input: { file_path: '/preferred.ts', path: '/fallback.ts' } }, + timestamp: Date.now(), + }); + expect(result).toBe('/preferred.ts'); + }); + + it('should return null for non-write tool names', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Read', + state: { input: { file_path: '/project/src/index.ts' } }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should return null when state has no input', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: { status: 'completed' }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should return null when state is null', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: null, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should return null when input has no file path fields', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Edit', + state: { input: { content: 'some code' } }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should return null when file_path is empty string', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: { input: { file_path: '' } }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should return null when path value is not a string', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: { input: { file_path: 123 } }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + + it('should work for all write tool names', () => { + for (const toolName of WRITE_TOOL_NAMES) { + const result = extractFilePathFromToolExecution({ + toolName, + state: { input: { file_path: `/project/${toolName}.ts` } }, + timestamp: Date.now(), + }); + expect(result).toBe(`/project/${toolName}.ts`); + } + }); + + it('should handle input being a non-object value', () => { + const result = extractFilePathFromToolExecution({ + toolName: 'Write', + state: { input: 'not an object' }, + timestamp: Date.now(), + }); + expect(result).toBeNull(); + }); + }); + + describe('detectLanguageFromPath', () => { + it('should detect TypeScript from .ts extension', () => { + expect(detectLanguageFromPath('/project/src/index.ts')).toBe('TypeScript'); + }); + + it('should detect TypeScript from .tsx extension', () => { + expect(detectLanguageFromPath('/project/src/App.tsx')).toBe('TypeScript'); + }); + + it('should detect JavaScript from .js extension', () => { + expect(detectLanguageFromPath('/project/src/index.js')).toBe('JavaScript'); + }); + + it('should detect JavaScript from .mjs extension', () => { + expect(detectLanguageFromPath('/project/src/config.mjs')).toBe('JavaScript'); + }); + + it('should detect JavaScript from .cjs extension', () => { + expect(detectLanguageFromPath('/project/src/config.cjs')).toBe('JavaScript'); + }); + + it('should detect Python from .py extension', () => { + expect(detectLanguageFromPath('/project/main.py')).toBe('Python'); + }); + + it('should detect Rust from .rs extension', () => { + expect(detectLanguageFromPath('/project/src/main.rs')).toBe('Rust'); + }); + + it('should detect Go from .go extension', () => { + expect(detectLanguageFromPath('/project/main.go')).toBe('Go'); + }); + + it('should detect C++ from .cpp extension', () => { + expect(detectLanguageFromPath('/project/src/main.cpp')).toBe('C++'); + }); + + it('should detect C from .h extension', () => { + expect(detectLanguageFromPath('/project/include/header.h')).toBe('C'); + }); + + it('should detect Shell Script from .sh extension', () => { + expect(detectLanguageFromPath('/project/scripts/build.sh')).toBe('Shell Script'); + }); + + it('should detect Markdown from .md extension', () => { + expect(detectLanguageFromPath('/project/README.md')).toBe('Markdown'); + }); + + it('should detect JSON from .json extension', () => { + expect(detectLanguageFromPath('/project/package.json')).toBe('JSON'); + }); + + it('should detect YAML from .yml extension', () => { + expect(detectLanguageFromPath('/project/.github/workflows/ci.yml')).toBe('YAML'); + }); + + it('should detect HCL from .tf extension', () => { + expect(detectLanguageFromPath('/infra/main.tf')).toBe('HCL'); + }); + + it('should detect Protocol Buffer from .proto extension', () => { + expect(detectLanguageFromPath('/proto/service.proto')).toBe('Protocol Buffer'); + }); + + it('should return undefined for unknown extensions', () => { + expect(detectLanguageFromPath('/project/data.xyz')).toBeUndefined(); + }); + + it('should return undefined for files without extension', () => { + expect(detectLanguageFromPath('/project/Makefile')).toBeUndefined(); + }); + + it('should handle case-insensitive extensions', () => { + expect(detectLanguageFromPath('/project/main.PY')).toBe('Python'); + expect(detectLanguageFromPath('/project/app.TSX')).toBe('TypeScript'); + }); + + it('should handle paths with multiple dots', () => { + expect(detectLanguageFromPath('/project/config.test.ts')).toBe('TypeScript'); + expect(detectLanguageFromPath('/project/styles.module.css')).toBe('CSS'); + }); + }); + + describe('sendFileHeartbeats', () => { + beforeEach(() => { + mockStore.get.mockImplementation((key: string, defaultVal: unknown) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeApiKey') return 'test-api-key-123'; + if (key === 'wakatimeDetailedTracking') return true; + return defaultVal; + }); + // CLI detected on PATH + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: 'wakatime-cli 1.73.1\n', + stderr: '', + }); + }); + + it('should early return when files array is empty', async () => { + await manager.sendFileHeartbeats([], 'My Project'); + // Only the detectCli mock is set up; no calls should have been made + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should skip when wakatimeEnabled is false', async () => { + mockStore.get.mockImplementation((key: string, defaultVal: unknown) => { + if (key === 'wakatimeEnabled') return false; + return defaultVal; + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project' + ); + + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should skip when wakatimeDetailedTracking is false', async () => { + mockStore.get.mockImplementation((key: string, defaultVal: unknown) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeApiKey') return 'test-api-key-123'; + if (key === 'wakatimeDetailedTracking') return false; + return defaultVal; + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project' + ); + + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should skip when no API key is available', async () => { + mockStore.get.mockImplementation((key: string, defaultVal: unknown) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeApiKey') return ''; + if (key === 'wakatimeDetailedTracking') return true; + return defaultVal; + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project' + ); + + // No heartbeat call should have been made (only the pre-set detectCli mock exists) + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should send a single file heartbeat with building category by default', async () => { + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project' + ); + + // Second call (after detectCli) is the heartbeat + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + expect(heartbeatCall[1]).toEqual([ + '--key', + 'test-api-key-123', + '--entity', + '/project/src/index.ts', + '--entity-type', + 'file', + '--write', + '--project', + 'My Project', + '--plugin', + 'maestro/1.0.0 maestro-wakatime/1.0.0', + '--category', + 'building', + '--time', + String(1708700000000 / 1000), + '--language', + 'TypeScript', + ]); + // No --extra-heartbeats for single file + expect(heartbeatCall[1]).not.toContain('--extra-heartbeats'); + // No stdin input for single file + expect(heartbeatCall[3]).toBeUndefined(); + }); + + it('should send ai coding category for file heartbeats with auto source', async () => { + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project', + undefined, + 'auto' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args[args.indexOf('--category') + 1]).toBe('ai coding'); + }); + + it('should send building category for file heartbeats with user source', async () => { + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project', + undefined, + 'user' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args[args.indexOf('--category') + 1]).toBe('building'); + }); + + it('should send multiple files with --extra-heartbeats via stdin', async () => { + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const files = [ + { filePath: '/project/src/index.ts', timestamp: 1708700000000 }, + { filePath: '/project/src/utils.py', timestamp: 1708700001000 }, + { filePath: '/project/src/main.go', timestamp: 1708700002000 }, + ]; + + await manager.sendFileHeartbeats(files, 'My Project'); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + + // Primary file is index.ts + expect(args).toContain('--entity'); + expect(args[args.indexOf('--entity') + 1]).toBe('/project/src/index.ts'); + expect(args).toContain('--extra-heartbeats'); + expect(args).toContain('--language'); + expect(args[args.indexOf('--language') + 1]).toBe('TypeScript'); + + // stdin should contain extra heartbeats JSON + const stdinOpts = heartbeatCall[3] as { input: string }; + expect(stdinOpts).toBeDefined(); + const extraArray = JSON.parse(stdinOpts.input); + expect(extraArray).toHaveLength(2); + expect(extraArray[0].entity).toBe('/project/src/utils.py'); + expect(extraArray[0].language).toBe('Python'); + expect(extraArray[0].type).toBe('file'); + expect(extraArray[0].is_write).toBe(true); + expect(extraArray[0].category).toBe('building'); + expect(extraArray[0].project).toBe('My Project'); + expect(extraArray[0].time).toBe(1708700001000 / 1000); + expect(extraArray[1].entity).toBe('/project/src/main.go'); + expect(extraArray[1].language).toBe('Go'); + }); + + it('should use ai coding category in extra heartbeats for auto source', async () => { + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const files = [ + { filePath: '/project/src/index.ts', timestamp: 1708700000000 }, + { filePath: '/project/src/utils.py', timestamp: 1708700001000 }, + ]; + + await manager.sendFileHeartbeats(files, 'My Project', undefined, 'auto'); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args[args.indexOf('--category') + 1]).toBe('ai coding'); + + const stdinOpts = heartbeatCall[3] as { input: string }; + const extraArray = JSON.parse(stdinOpts.input); + expect(extraArray[0].category).toBe('ai coding'); + }); + + it('should include branch info when projectCwd is provided', async () => { + // Use mockImplementation to avoid mock ordering issues with fire-and-forget checkForUpdate + vi.mocked(execFileNoThrow) + .mockReset() + .mockImplementation(async (cmd: any, args: any) => { + if (args?.[0] === '--version') { + return { exitCode: 0, stdout: 'wakatime-cli 1.73.1\n', stderr: '' }; + } + if (cmd === 'git') { + return { exitCode: 0, stdout: 'feat/my-branch\n', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project', + '/project' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args).toContain('--alternate-branch'); + expect(args[args.indexOf('--alternate-branch') + 1]).toBe('feat/my-branch'); + }); + + it('should include branch in extra heartbeats when available', async () => { + // Use mockImplementation to avoid mock ordering issues with fire-and-forget checkForUpdate + vi.mocked(execFileNoThrow) + .mockReset() + .mockImplementation(async (cmd: any, args: any) => { + if (args?.[0] === '--version') { + return { exitCode: 0, stdout: 'wakatime-cli 1.73.1\n', stderr: '' }; + } + if (cmd === 'git') { + return { exitCode: 0, stdout: 'main\n', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); + + const files = [ + { filePath: '/project/src/index.ts', timestamp: 1708700000000 }, + { filePath: '/project/src/utils.ts', timestamp: 1708700001000 }, + ]; + + await manager.sendFileHeartbeats(files, 'My Project', '/project'); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const stdinOpts = heartbeatCall[3] as { input: string }; + const extraArray = JSON.parse(stdinOpts.input); + expect(extraArray[0].branch).toBe('main'); + }); + + it('should omit language for files with unknown extensions', async () => { + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/data.xyz', timestamp: 1708700000000 }], + 'My Project' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args).not.toContain('--language'); + }); + + it('should log success with file count', async () => { + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [ + { filePath: '/project/src/a.ts', timestamp: 1708700000000 }, + { filePath: '/project/src/b.ts', timestamp: 1708700001000 }, + ], + 'My Project' + ); + + expect(logger.info).toHaveBeenCalledWith('Sent file heartbeats', '[WakaTime]', { count: 2 }); + }); + + it('should convert timestamps to seconds for WakaTime CLI', async () => { + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const timestamp = 1708700000000; // milliseconds + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp }], + 'My Project' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + const timeIndex = args.indexOf('--time'); + expect(args[timeIndex + 1]).toBe(String(timestamp / 1000)); + }); + + it('should fall back to ~/.wakatime.cfg for API key', async () => { + mockStore.get.mockImplementation((key: string, defaultVal: unknown) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeApiKey') return ''; + if (key === 'wakatimeDetailedTracking') return true; + return defaultVal; + }); + + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + return String(p).endsWith('.wakatime.cfg'); + }); + vi.mocked(fs.readFileSync).mockReturnValue('[settings]\napi_key = cfg-key-456\n'); + + // The heartbeat exec call + vi.mocked(execFileNoThrow).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + await manager.sendFileHeartbeats( + [{ filePath: '/project/src/index.ts', timestamp: 1708700000000 }], + 'My Project' + ); + + const calls = vi.mocked(execFileNoThrow).mock.calls; + const heartbeatCall = calls[calls.length - 1]; + const args = heartbeatCall[1] as string[]; + expect(args).toContain('cfg-key-456'); + }); + }); }); diff --git a/src/main/process-listeners/__tests__/wakatime-listener.test.ts b/src/main/process-listeners/__tests__/wakatime-listener.test.ts index 40ca7f410..a705ae927 100644 --- a/src/main/process-listeners/__tests__/wakatime-listener.test.ts +++ b/src/main/process-listeners/__tests__/wakatime-listener.test.ts @@ -2,10 +2,12 @@ * Tests for WakaTime heartbeat listener. * Verifies that data and thinking-chunk events trigger heartbeats for interactive sessions, * query-complete events trigger heartbeats for batch/auto-run, - * and exit events clean up sessions. + * tool-execution events accumulate file paths for file-level heartbeats, + * usage events flush file heartbeats for interactive sessions (debounced), + * and exit events clean up sessions and pending file data. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { setupWakaTimeListener } from '../wakatime-listener'; import type { ProcessManager } from '../../process-manager'; import type { WakaTimeManager } from '../../wakatime-manager'; @@ -32,6 +34,7 @@ describe('WakaTime Listener', () => { mockWakaTimeManager = { sendHeartbeat: vi.fn().mockResolvedValue(undefined), + sendFileHeartbeats: vi.fn().mockResolvedValue(undefined), removeSession: vi.fn(), } as unknown as WakaTimeManager; @@ -44,12 +47,14 @@ describe('WakaTime Listener', () => { }; }); - it('should register data, thinking-chunk, query-complete, and exit event listeners', () => { + it('should register data, thinking-chunk, tool-execution, query-complete, usage, and exit event listeners', () => { setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); expect(mockProcessManager.on).toHaveBeenCalledWith('data', expect.any(Function)); expect(mockProcessManager.on).toHaveBeenCalledWith('thinking-chunk', expect.any(Function)); + expect(mockProcessManager.on).toHaveBeenCalledWith('tool-execution', expect.any(Function)); expect(mockProcessManager.on).toHaveBeenCalledWith('query-complete', expect.any(Function)); + expect(mockProcessManager.on).toHaveBeenCalledWith('usage', expect.any(Function)); expect(mockProcessManager.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); @@ -72,7 +77,8 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( 'session-abc', 'project', - '/home/user/project' + '/home/user/project', + undefined ); }); @@ -95,7 +101,8 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( 'session-thinking', 'project', - '/home/user/project' + '/home/user/project', + undefined ); }); @@ -146,11 +153,12 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( 'session-no-path', 'fallback', - '/home/user/fallback' + '/home/user/fallback', + undefined ); }); - it('should send heartbeat on query-complete with projectPath', () => { + it('should send heartbeat on query-complete with projectPath and source', () => { setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); const handler = eventHandlers.get('query-complete'); @@ -169,7 +177,8 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( 'session-abc', 'project', - '/home/user/project' + '/home/user/project', + 'user' ); }); @@ -190,7 +199,56 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( 'session-fallback', 'session-fallback', - undefined + undefined, + 'user' + ); + }); + + it('should forward querySource auto on data event', () => { + vi.mocked(mockProcessManager.get).mockReturnValue({ + sessionId: 'session-auto', + toolType: 'claude-code', + cwd: '/home/user/project', + pid: 1234, + isTerminal: false, + startTime: Date.now(), + projectPath: '/home/user/project', + querySource: 'auto', + } as any); + + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + const handler = eventHandlers.get('data'); + handler?.('session-auto', 'output'); + + expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( + 'session-auto', + 'project', + '/home/user/project', + 'auto' + ); + }); + + it('should forward source auto on query-complete', () => { + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + const handler = eventHandlers.get('query-complete'); + const queryData: QueryCompleteData = { + sessionId: 'session-auto', + agentType: 'claude-code', + source: 'auto', + startTime: Date.now(), + duration: 5000, + projectPath: '/home/user/project', + }; + + handler?.('session-auto', queryData); + + expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith( + 'session-auto', + 'project', + '/home/user/project', + 'auto' ); }); @@ -294,4 +352,627 @@ describe('WakaTime Listener', () => { expect(mockWakaTimeManager.sendHeartbeat).not.toHaveBeenCalled(); }); + + it('should subscribe to wakatimeDetailedTracking changes', () => { + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + expect(mockSettingsStore.onDidChange).toHaveBeenCalledWith( + 'wakatimeDetailedTracking', + expect.any(Function) + ); + }); + + describe('tool-execution file collection', () => { + let toolExecutionHandler: (...args: unknown[]) => void; + let queryCompleteHandler: (...args: unknown[]) => void; + + beforeEach(() => { + // Enable both wakatime and detailed tracking + mockSettingsStore.get.mockImplementation((key: string, defaultValue?: any) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeDetailedTracking') return true; + return defaultValue; + }); + + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + toolExecutionHandler = eventHandlers.get('tool-execution')!; + queryCompleteHandler = eventHandlers.get('query-complete')!; + }); + + it('should accumulate file paths from write tool executions', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/src/index.ts' } }, + timestamp: 1000, + }); + + // Trigger query-complete to flush + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/index.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + 'user' + ); + }); + + it('should forward auto source to sendFileHeartbeats on query-complete', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/src/index.ts' } }, + timestamp: 1000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'auto', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/index.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + 'auto' + ); + }); + + it('should ignore non-write tool executions', () => { + toolExecutionHandler('session-1', { + toolName: 'Read', + state: { input: { file_path: '/home/user/project/src/index.ts' } }, + timestamp: 1000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should deduplicate file paths keeping latest timestamp', () => { + toolExecutionHandler('session-1', { + toolName: 'Edit', + state: { input: { file_path: '/home/user/project/src/app.ts' } }, + timestamp: 1000, + }); + toolExecutionHandler('session-1', { + toolName: 'Edit', + state: { input: { file_path: '/home/user/project/src/app.ts' } }, + timestamp: 2000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/app.ts', timestamp: 2000 }], + 'project', + '/home/user/project', + 'user' + ); + }); + + it('should resolve relative file paths using projectPath', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: 'src/utils.ts' } }, + timestamp: 1000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/utils.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + 'user' + ); + }); + + it('should not resolve already-absolute file paths', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/absolute/path/file.ts' } }, + timestamp: 1000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/absolute/path/file.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + 'user' + ); + }); + + it('should clear pending files after flushing on query-complete', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // First query-complete should flush + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledTimes(1); + + // Second query-complete should NOT call sendFileHeartbeats (already flushed) + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledTimes(1); + }); + + it('should skip tool-execution collection when wakatime is disabled', () => { + // Disable wakatime via onDidChange callback + const enabledCallback = mockSettingsStore.onDidChange.mock.calls.find( + (c: any[]) => c[0] === 'wakatimeEnabled' + )[1]; + enabledCallback(false); + + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // Re-enable for query-complete to fire + enabledCallback(true); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should skip tool-execution collection when detailed tracking is disabled', () => { + // Disable detailed tracking via onDidChange callback + const detailedCallback = mockSettingsStore.onDidChange.mock.calls.find( + (c: any[]) => c[0] === 'wakatimeDetailedTracking' + )[1]; + detailedCallback(false); + + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should not flush file heartbeats on query-complete when detailed tracking is disabled', () => { + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // Disable detailed tracking before query-complete + const detailedCallback = mockSettingsStore.onDidChange.mock.calls.find( + (c: any[]) => c[0] === 'wakatimeDetailedTracking' + )[1]; + detailedCallback(false); + + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + // Regular heartbeat should still be sent + expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalled(); + // But file heartbeats should not + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + }); + + describe('exit cleanup of pending files', () => { + it('should clean up pending files on exit', () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue?: any) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeDetailedTracking') return true; + return defaultValue; + }); + + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + const toolExecutionHandler = eventHandlers.get('tool-execution')!; + const exitHandler = eventHandlers.get('exit')!; + const queryCompleteHandler = eventHandlers.get('query-complete')!; + + // Accumulate a file + toolExecutionHandler('session-1', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // Exit cleans up + exitHandler('session-1'); + + // query-complete should not find any pending files + queryCompleteHandler('session-1', { + sessionId: 'session-1', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + }); + + describe('usage-based file flush', () => { + let toolExecutionHandler: (...args: unknown[]) => void; + let usageHandler: (...args: unknown[]) => void; + let queryCompleteHandler: (...args: unknown[]) => void; + let exitHandler: (...args: unknown[]) => void; + + const usageStats = { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.01, + contextWindow: 200000, + }; + + beforeEach(() => { + vi.useFakeTimers(); + + // Enable both wakatime and detailed tracking + mockSettingsStore.get.mockImplementation((key: string, defaultValue?: any) => { + if (key === 'wakatimeEnabled') return true; + if (key === 'wakatimeDetailedTracking') return true; + return defaultValue; + }); + + vi.mocked(mockProcessManager.get).mockReturnValue({ + sessionId: 'session-interactive', + toolType: 'claude-code', + cwd: '/home/user/project', + pid: 1234, + isTerminal: false, + startTime: Date.now(), + projectPath: '/home/user/project', + } as any); + + setupWakaTimeListener(mockProcessManager, mockWakaTimeManager, mockSettingsStore); + + toolExecutionHandler = eventHandlers.get('tool-execution')!; + usageHandler = eventHandlers.get('usage')!; + queryCompleteHandler = eventHandlers.get('query-complete')!; + exitHandler = eventHandlers.get('exit')!; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should flush file heartbeats after usage event debounce', () => { + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/src/app.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + + // Should NOT have flushed yet (debounce pending) + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + + // Advance past debounce delay + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/app.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + undefined + ); + }); + + it('should debounce multiple rapid usage events into one flush', () => { + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // Fire usage three times in quick succession + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(200); + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(200); + + // Add another file between usage events + toolExecutionHandler('session-interactive', { + toolName: 'Edit', + state: { input: { file_path: '/home/user/project/b.ts' } }, + timestamp: 2000, + }); + + usageHandler('session-interactive', usageStats); + + // Still not flushed + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + + // Advance past the final debounce + vi.advanceTimersByTime(500); + + // Should flush once with both files + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledTimes(1); + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + expect.arrayContaining([ + { filePath: '/home/user/project/a.ts', timestamp: 1000 }, + { filePath: '/home/user/project/b.ts', timestamp: 2000 }, + ]), + 'project', + '/home/user/project', + undefined + ); + }); + + it('should not double-flush when query-complete fires after usage accumulation', () => { + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // usage fires (starts debounce timer) + usageHandler('session-interactive', usageStats); + + // Before debounce fires, query-complete fires (batch scenario) + queryCompleteHandler('session-interactive', { + sessionId: 'session-interactive', + agentType: 'claude-code', + source: 'user', + startTime: 0, + duration: 5000, + projectPath: '/home/user/project', + } as QueryCompleteData); + + // query-complete already flushed + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledTimes(1); + + // Advance past debounce -- should NOT flush again + vi.advanceTimersByTime(500); + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledTimes(1); + }); + + it('should skip usage flush when no pending files exist', () => { + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should skip usage flush when wakatime is disabled', () => { + const enabledCallback = mockSettingsStore.onDidChange.mock.calls.find( + (c: any[]) => c[0] === 'wakatimeEnabled' + )[1]; + enabledCallback(false); + + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + // Re-enable so usage handler runs, but no files accumulated + enabledCallback(true); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should skip usage flush when detailed tracking is disabled', () => { + const detailedCallback = mockSettingsStore.onDidChange.mock.calls.find( + (c: any[]) => c[0] === 'wakatimeDetailedTracking' + )[1]; + detailedCallback(false); + + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should resolve relative file paths using managedProcess.projectPath', () => { + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: 'src/utils.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/src/utils.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + undefined + ); + }); + + it('should fall back to cwd when projectPath is missing', () => { + vi.mocked(mockProcessManager.get).mockReturnValue({ + sessionId: 'session-interactive', + toolType: 'claude-code', + cwd: '/home/user/fallback', + pid: 1234, + isTerminal: false, + startTime: Date.now(), + } as any); + + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: 'src/utils.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/fallback/src/utils.ts', timestamp: 1000 }], + 'fallback', + '/home/user/fallback', + undefined + ); + }); + + it('should clean up usage flush timer on exit', () => { + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + + // Exit before debounce fires + exitHandler('session-interactive'); + + // Advance past debounce -- should NOT flush + vi.advanceTimersByTime(500); + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + + it('should forward querySource auto on usage flush', () => { + vi.mocked(mockProcessManager.get).mockReturnValue({ + sessionId: 'session-interactive', + toolType: 'claude-code', + cwd: '/home/user/project', + pid: 1234, + isTerminal: false, + startTime: Date.now(), + projectPath: '/home/user/project', + querySource: 'auto', + } as any); + + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith( + [{ filePath: '/home/user/project/a.ts', timestamp: 1000 }], + 'project', + '/home/user/project', + 'auto' + ); + }); + + it('should skip flush for terminal sessions', () => { + vi.mocked(mockProcessManager.get).mockReturnValue({ + sessionId: 'session-interactive', + toolType: 'terminal', + cwd: '/home/user', + pid: 1234, + isTerminal: true, + startTime: Date.now(), + } as any); + + toolExecutionHandler('session-interactive', { + toolName: 'Write', + state: { input: { file_path: '/home/user/project/a.ts' } }, + timestamp: 1000, + }); + + usageHandler('session-interactive', usageStats); + vi.advanceTimersByTime(500); + + expect(mockWakaTimeManager.sendFileHeartbeats).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts index 78ced7e79..e6f29886c 100644 --- a/src/main/process-listeners/wakatime-listener.ts +++ b/src/main/process-listeners/wakatime-listener.ts @@ -7,13 +7,18 @@ * The `thinking-chunk` event fires while the AI is actively reasoning (extended thinking). * Together these ensure heartbeats cover the full duration of AI activity. * The `query-complete` event fires only for batch/auto-run processes. + * + * When detailed tracking is enabled, `tool-execution` events accumulate file paths + * from write operations, which are flushed as file-level heartbeats on `query-complete` + * (batch) or after a debounced `usage` event (interactive). */ import path from 'path'; import type Store from 'electron-store'; import type { ProcessManager } from '../process-manager'; -import type { QueryCompleteData } from '../process-manager/types'; +import type { QueryCompleteData, ToolExecution, UsageStats } from '../process-manager/types'; import type { WakaTimeManager } from '../wakatime-manager'; +import { extractFilePathFromToolExecution } from '../wakatime-manager'; import type { MaestroSettings } from '../stores/types'; /** Helper to send a heartbeat for a managed process */ @@ -26,12 +31,20 @@ function heartbeatForSession( if (!managedProcess || managedProcess.isTerminal) return; const projectDir = managedProcess.projectPath || managedProcess.cwd; const projectName = projectDir ? path.basename(projectDir) : sessionId; - void wakaTimeManager.sendHeartbeat(sessionId, projectName, projectDir); + void wakaTimeManager.sendHeartbeat( + sessionId, + projectName, + projectDir, + managedProcess.querySource + ); } +/** Debounce delay for flushing file heartbeats after a `usage` event (ms) */ +const USAGE_FLUSH_DELAY_MS = 500; + /** * Sets up the WakaTime heartbeat listener on data, thinking-chunk, - * query-complete, and exit events. + * tool-execution, usage, query-complete, and exit events. * Heartbeat calls are fire-and-forget (no await needed in the listener). */ export function setupWakaTimeListener( @@ -46,6 +59,44 @@ export function setupWakaTimeListener( enabled = !!v; }); + // Cache detailed tracking state for file-level heartbeats + let detailedEnabled = settingsStore.get('wakatimeDetailedTracking', false) as boolean; + settingsStore.onDidChange('wakatimeDetailedTracking', (val: unknown) => { + detailedEnabled = !!val; + }); + + // Per-session accumulator for file paths from tool-execution events. + // Outer key: sessionId, inner key: filePath (deduplicates, keeping latest timestamp). + const pendingFiles = new Map>(); + + // Per-session debounce timers for usage-based file flush. + const usageFlushTimers = new Map>(); + + /** Flush accumulated file heartbeats for a session. */ + function flushPendingFiles( + sessionId: string, + projectDir: string | undefined, + projectName: string, + source?: 'user' | 'auto' + ): void { + const sessionFiles = pendingFiles.get(sessionId); + if (!sessionFiles || sessionFiles.size === 0) return; + + const filesArray = Array.from(sessionFiles.values()) + .map((f) => ({ + filePath: path.isAbsolute(f.filePath) + ? f.filePath + : projectDir + ? path.resolve(projectDir, f.filePath) + : null, + timestamp: f.timestamp, + })) + .filter((f): f is { filePath: string; timestamp: number } => f.filePath !== null); + + void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, projectDir, source); + pendingFiles.delete(sessionId); + } + // Send heartbeat on any AI output (covers interactive sessions) // The 2-minute debounce in WakaTimeManager prevents flooding processManager.on('data', (sessionId: string) => { @@ -60,17 +111,90 @@ export function setupWakaTimeListener( heartbeatForSession(processManager, wakaTimeManager, sessionId); }); + // Collect file paths from write-tool executions for file-level heartbeats + processManager.on('tool-execution', (sessionId: string, toolExecution: ToolExecution) => { + if (!enabled || !detailedEnabled) return; + + const filePath = extractFilePathFromToolExecution(toolExecution); + if (!filePath) return; + + let sessionFiles = pendingFiles.get(sessionId); + if (!sessionFiles) { + sessionFiles = new Map(); + pendingFiles.set(sessionId, sessionFiles); + } + sessionFiles.set(filePath, { filePath, timestamp: toolExecution.timestamp }); + }); + // Also send heartbeat on query-complete for batch/auto-run processes processManager.on('query-complete', (_sessionId: string, queryData: QueryCompleteData) => { if (!enabled) return; const projectName = queryData.projectPath ? path.basename(queryData.projectPath) : queryData.sessionId; - void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath); + void wakaTimeManager.sendHeartbeat( + queryData.sessionId, + projectName, + queryData.projectPath, + queryData.source + ); + + // Flush accumulated file heartbeats (or clear if detailed tracking was disabled) + if (detailedEnabled) { + flushPendingFiles(queryData.sessionId, queryData.projectPath, projectName, queryData.source); + } else { + pendingFiles.delete(queryData.sessionId); + } + + // Cancel any pending usage-based flush since query-complete already flushed + const timer = usageFlushTimers.get(queryData.sessionId); + if (timer) { + clearTimeout(timer); + usageFlushTimers.delete(queryData.sessionId); + } + }); + + // Flush accumulated file heartbeats on usage events (fires for ALL sessions, + // including interactive). Debounced per-session since usage can fire multiple + // times per turn. + processManager.on('usage', (sessionId: string, _usageStats: UsageStats) => { + if (!enabled) return; + if (!detailedEnabled) { + pendingFiles.delete(sessionId); + return; + } + if (!pendingFiles.has(sessionId) || pendingFiles.get(sessionId)!.size === 0) return; + + // Reset existing timer for this session (debounce) + const existingTimer = usageFlushTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + usageFlushTimers.set( + sessionId, + setTimeout(() => { + usageFlushTimers.delete(sessionId); + + const managedProcess = processManager.get(sessionId); + if (!managedProcess || managedProcess.isTerminal) return; + + const projectDir = managedProcess.projectPath || managedProcess.cwd; + const projectName = projectDir ? path.basename(projectDir) : sessionId; + + flushPendingFiles(sessionId, projectDir, projectName, managedProcess.querySource); + }, USAGE_FLUSH_DELAY_MS) + ); }); - // Clean up debounce tracking when a process exits + // Clean up debounce tracking, pending file data, and flush timers on exit processManager.on('exit', (sessionId: string) => { wakaTimeManager.removeSession(sessionId); + pendingFiles.delete(sessionId); + const timer = usageFlushTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + usageFlushTimers.delete(sessionId); + } }); } diff --git a/src/main/stores/defaults.ts b/src/main/stores/defaults.ts index 1dad1c006..04b59dbea 100644 --- a/src/main/stores/defaults.ts +++ b/src/main/stores/defaults.ts @@ -68,6 +68,7 @@ export const SETTINGS_DEFAULTS: MaestroSettings = { installationId: null, wakatimeEnabled: false, wakatimeApiKey: '', + wakatimeDetailedTracking: false, totalActiveTimeMs: 0, }; diff --git a/src/main/stores/types.ts b/src/main/stores/types.ts index a9311d371..0028f49f7 100644 --- a/src/main/stores/types.ts +++ b/src/main/stores/types.ts @@ -72,6 +72,7 @@ export interface MaestroSettings { // WakaTime integration wakatimeEnabled: boolean; wakatimeApiKey: string; + wakatimeDetailedTracking: boolean; // Standalone hands-on time tracker (migrated from globalStats.totalActiveTimeMs) totalActiveTimeMs: number; // Allow dynamic settings keys (electron-store is a key-value store diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts index ad8c23972..8edf52936 100644 --- a/src/main/wakatime-manager.ts +++ b/src/main/wakatime-manager.ts @@ -24,6 +24,101 @@ const LOG_CONTEXT = '[WakaTime]'; const HEARTBEAT_DEBOUNCE_MS = 120_000; // 2 minutes - WakaTime deduplicates within this window const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // Check for CLI updates once per day +/** Map file extensions (without dot, lowercase) to WakaTime language names */ +const EXTENSION_LANGUAGE_MAP = new Map([ + ['ts', 'TypeScript'], + ['tsx', 'TypeScript'], + ['js', 'JavaScript'], + ['jsx', 'JavaScript'], + ['mjs', 'JavaScript'], + ['cjs', 'JavaScript'], + ['py', 'Python'], + ['rb', 'Ruby'], + ['rs', 'Rust'], + ['go', 'Go'], + ['java', 'Java'], + ['kt', 'Kotlin'], + ['swift', 'Swift'], + ['c', 'C'], + ['cpp', 'C++'], + ['h', 'C'], + ['hpp', 'C++'], + ['cs', 'C#'], + ['php', 'PHP'], + ['ex', 'Elixir'], + ['exs', 'Elixir'], + ['dart', 'Dart'], + ['json', 'JSON'], + ['yaml', 'YAML'], + ['yml', 'YAML'], + ['toml', 'TOML'], + ['md', 'Markdown'], + ['html', 'HTML'], + ['css', 'CSS'], + ['scss', 'SCSS'], + ['less', 'LESS'], + ['sql', 'SQL'], + ['sh', 'Shell Script'], + ['bash', 'Shell Script'], + ['zsh', 'Shell Script'], + ['vue', 'Vue.js'], + ['svelte', 'Svelte'], + ['lua', 'Lua'], + ['zig', 'Zig'], + ['r', 'R'], + ['scala', 'Scala'], + ['clj', 'Clojure'], + ['erl', 'Erlang'], + ['hs', 'Haskell'], + ['ml', 'OCaml'], + ['nim', 'Nim'], + ['cr', 'Crystal'], + ['tf', 'HCL'], + ['proto', 'Protocol Buffer'], +]); + +/** + * Detect the WakaTime language name from a file path's extension. + * Returns undefined if the extension is not recognized. + */ +export function detectLanguageFromPath(filePath: string): string | undefined { + const ext = path.extname(filePath).replace(/^\./, '').toLowerCase(); + if (!ext) return undefined; + return EXTENSION_LANGUAGE_MAP.get(ext); +} + +/** Tool names that represent file write operations across supported agents */ +export const WRITE_TOOL_NAMES = new Set([ + 'Write', + 'Edit', + 'write_to_file', + 'str_replace_based_edit_tool', + 'create_file', + 'write', + 'patch', + 'NotebookEdit', +]); + +/** + * Extract a file path from a tool-execution event if the tool is a write operation. + * Returns null if the tool is not a write operation or no file path is found. + */ +export function extractFilePathFromToolExecution(toolExecution: { + toolName: string; + state: unknown; + timestamp: number; +}): string | null { + if (!WRITE_TOOL_NAMES.has(toolExecution.toolName)) return null; + + const input = (toolExecution.state as any)?.input; + if (!input || typeof input !== 'object') return null; + + const filePath = input.file_path ?? input.path; + if (typeof filePath === 'string' && filePath.length > 0) return filePath; + + return null; +} + /** Map Node.js platform to WakaTime release naming */ function getWakaTimePlatform(): string | null { switch (process.platform) { @@ -140,10 +235,13 @@ function fetchJson(url: string, maxRedirects = 5): Promise { }); } +/** How long a successfully-detected branch is cached before re-checking (5 min). */ +const BRANCH_CACHE_TTL_MS = 5 * 60 * 1000; + export class WakaTimeManager { private settingsStore: Store; private lastHeartbeatPerSession: Map = new Map(); - private branchCache: Map = new Map(); + private branchCache: Map = new Map(); private languageCache: Map = new Map(); private cliPath: string | null = null; private cliDetected = false; @@ -419,20 +517,33 @@ export class WakaTimeManager { /** * Detect the current git branch for a project directory. - * Result is cached per session to avoid running git on every heartbeat. + * Successful results are cached per session with a TTL so branch switches + * are picked up. Failures are never cached — the next heartbeat retries. */ private async detectBranch(sessionId: string, cwd: string): Promise { const cached = this.branchCache.get(sessionId); - if (cached !== undefined) return cached || null; + if (cached && cached.branch && Date.now() - cached.timestamp < BRANCH_CACHE_TTL_MS) { + return cached.branch; + } const result = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd); const branch = result.exitCode === 0 ? result.stdout.trim() : ''; - this.branchCache.set(sessionId, branch); + if (branch) { + this.branchCache.set(sessionId, { branch, timestamp: Date.now() }); + } else { + // Don't cache failures — retry on next heartbeat + this.branchCache.delete(sessionId); + } return branch || null; } /** Send a heartbeat for a session's activity */ - async sendHeartbeat(sessionId: string, projectName: string, projectCwd?: string): Promise { + async sendHeartbeat( + sessionId: string, + projectName: string, + projectCwd?: string, + source?: 'user' | 'auto' + ): Promise { // Check if enabled const enabled = this.settingsStore.get('wakatimeEnabled', false); if (!enabled) return; @@ -468,7 +579,7 @@ export class WakaTimeManager { '--plugin', `maestro/${app.getVersion()} maestro-wakatime/${app.getVersion()}`, '--category', - 'ai coding', + source === 'auto' ? 'ai coding' : 'building', ]; // Detect project language from manifest files in cwd @@ -495,6 +606,100 @@ export class WakaTimeManager { } } + /** + * Send file-level heartbeats for files modified during a query. + * The first file is sent as the primary heartbeat via CLI args; + * remaining files are batched via --extra-heartbeats on stdin. + */ + async sendFileHeartbeats( + files: Array<{ filePath: string; timestamp: number }>, + projectName: string, + projectCwd?: string, + source?: 'user' | 'auto' + ): Promise { + if (files.length === 0) return; + + const enabled = this.settingsStore.get('wakatimeEnabled', false); + if (!enabled) return; + + const detailedTracking = this.settingsStore.get('wakatimeDetailedTracking', false); + if (!detailedTracking) return; + + let apiKey = this.settingsStore.get('wakatimeApiKey', '') as string; + if (!apiKey) { + apiKey = this.readApiKeyFromConfig(); + } + if (!apiKey) return; + + if (!(await this.ensureCliInstalled())) { + logger.warn('WakaTime CLI not available — skipping file heartbeats', LOG_CONTEXT); + return; + } + + const branch = projectCwd ? await this.detectBranch(`file:${projectCwd}`, projectCwd) : null; + + const primary = files[0]; + const args = [ + '--key', + apiKey, + '--entity', + primary.filePath, + '--entity-type', + 'file', + '--write', + '--project', + projectName, + '--plugin', + `maestro/${app.getVersion()} maestro-wakatime/${app.getVersion()}`, + '--category', + source === 'auto' ? 'ai coding' : 'building', + '--time', + String(primary.timestamp / 1000), + ]; + + const primaryLanguage = detectLanguageFromPath(primary.filePath); + if (primaryLanguage) { + args.push('--language', primaryLanguage); + } + + if (branch) { + args.push('--alternate-branch', branch); + } + + const extraFiles = files.slice(1); + if (extraFiles.length > 0) { + args.push('--extra-heartbeats'); + } + + const extraArray = extraFiles.map((f) => { + const obj: Record = { + entity: f.filePath, + type: 'file', + is_write: true, + time: f.timestamp / 1000, + category: source === 'auto' ? 'ai coding' : 'building', + project: projectName, + }; + const lang = detectLanguageFromPath(f.filePath); + if (lang) obj.language = lang; + if (branch) obj.branch = branch; + return obj; + }); + + const result = await execFileNoThrow( + this.cliPath!, + args, + projectCwd, + extraFiles.length > 0 ? { input: JSON.stringify(extraArray) } : undefined + ); + + if (result.exitCode === 0) { + logger.info('Sent file heartbeats', LOG_CONTEXT, { count: files.length }); + } else { + logger.warn(`File heartbeats failed: ${result.stderr}`, LOG_CONTEXT, { count: files.length }); + } + } + /** Get the resolved CLI path (null if not yet detected/installed) */ getCliPath(): string | null { return this.cliPath; diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 8e47f9259..e67f7b9ea 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -415,6 +415,8 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro setWakatimeApiKey, wakatimeEnabled, setWakatimeEnabled, + wakatimeDetailedTracking, + setWakatimeDetailedTracking, // Window chrome settings useNativeTitleBar, setUseNativeTitleBar, @@ -2200,6 +2202,38 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro

)} + {/* Detailed file tracking toggle (only shown when enabled) */} + {wakatimeEnabled && ( +
+
+

+ Detailed file tracking +

+

+ Track per-file write activity. Sends file paths (not content) to WakaTime. +

+
+ +
+ )} + {/* API Key Input (only shown when enabled) */} {wakatimeEnabled && (
diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index f91a468ad..1309088a9 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -297,6 +297,8 @@ export interface UseSettingsReturn { setWakatimeApiKey: (value: string) => void; wakatimeEnabled: boolean; setWakatimeEnabled: (value: boolean) => void; + wakatimeDetailedTracking: boolean; + setWakatimeDetailedTracking: (value: boolean) => void; // Window chrome settings useNativeTitleBar: boolean; diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index e623184e2..c10e6071e 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -240,6 +240,7 @@ export interface SettingsStoreState { directorNotesSettings: DirectorNotesSettings; wakatimeApiKey: string; wakatimeEnabled: boolean; + wakatimeDetailedTracking: boolean; useNativeTitleBar: boolean; autoHideMenuBar: boolean; } @@ -309,6 +310,7 @@ export interface SettingsStoreActions { setDirectorNotesSettings: (value: DirectorNotesSettings) => void; setWakatimeApiKey: (value: string) => void; setWakatimeEnabled: (value: boolean) => void; + setWakatimeDetailedTracking: (value: boolean) => void; setUseNativeTitleBar: (value: boolean) => void; setAutoHideMenuBar: (value: boolean) => void; @@ -454,6 +456,7 @@ export const useSettingsStore = create()((set, get) => ({ directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, wakatimeApiKey: '', wakatimeEnabled: false, + wakatimeDetailedTracking: false, useNativeTitleBar: false, autoHideMenuBar: false, @@ -784,6 +787,11 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('wakatimeEnabled', value); }, + setWakatimeDetailedTracking: (value) => { + set({ wakatimeDetailedTracking: value }); + window.maestro.settings.set('wakatimeDetailedTracking', value); + }, + setUseNativeTitleBar: (value) => { set({ useNativeTitleBar: value }); window.maestro.settings.set('useNativeTitleBar', value); @@ -1679,6 +1687,9 @@ export async function loadAllSettings(): Promise { if (allSettings['wakatimeEnabled'] !== undefined) patch.wakatimeEnabled = allSettings['wakatimeEnabled'] as boolean; + if (allSettings['wakatimeDetailedTracking'] !== undefined) + patch.wakatimeDetailedTracking = allSettings['wakatimeDetailedTracking'] as boolean; + if (allSettings['useNativeTitleBar'] !== undefined) patch.useNativeTitleBar = allSettings['useNativeTitleBar'] as boolean; @@ -1795,6 +1806,7 @@ export function getSettingsActions() { setDirectorNotesSettings: state.setDirectorNotesSettings, setWakatimeApiKey: state.setWakatimeApiKey, setWakatimeEnabled: state.setWakatimeEnabled, + setWakatimeDetailedTracking: state.setWakatimeDetailedTracking, setUseNativeTitleBar: state.setUseNativeTitleBar, setAutoHideMenuBar: state.setAutoHideMenuBar, };