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
+ Detailed file tracking +
++ Track per-file write activity. Sends file paths (not content) to WakaTime. +
+