From c44dbcdf41c8ae37aaa5659bbb3e9ee9827c8fad Mon Sep 17 00:00:00 2001 From: Kian Date: Mon, 23 Feb 2026 20:36:04 -0600 Subject: [PATCH 01/12] feat: add wakatimeDetailedTracking setting and file-extension language map Add new `wakatimeDetailedTracking` boolean setting (default false) to types, defaults, settingsStore, and useSettings hook. This setting will gate file-level heartbeat collection in a future phase. Add EXTENSION_LANGUAGE_MAP (50+ extensions) and exported detectLanguageFromPath() helper to wakatime-manager.ts for resolving file paths to WakaTime language names. Includes 22 new tests for detectLanguageFromPath covering common extensions, case insensitivity, multi-dot paths, and unknown extensions. --- src/__tests__/main/wakatime-manager.test.ts | 86 ++++++++++++++++++++- src/main/stores/defaults.ts | 1 + src/main/stores/types.ts | 1 + src/main/wakatime-manager.ts | 63 +++++++++++++++ src/renderer/hooks/settings/useSettings.ts | 2 + src/renderer/stores/settingsStore.ts | 11 +++ 6 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts index 525f5e35e..8551030b5 100644 --- a/src/__tests__/main/wakatime-manager.test.ts +++ b/src/__tests__/main/wakatime-manager.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WakaTimeManager } from '../../main/wakatime-manager'; +import { WakaTimeManager, detectLanguageFromPath } from '../../main/wakatime-manager'; // Mock electron vi.mock('electron', () => ({ @@ -673,4 +673,88 @@ describe('WakaTimeManager', () => { expect(execFileNoThrow).toHaveBeenCalledTimes(3); }); }); + + 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'); + }); + }); }); 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..d4a7508af 100644 --- a/src/main/wakatime-manager.ts +++ b/src/main/wakatime-manager.ts @@ -24,6 +24,69 @@ 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); +} + /** Map Node.js platform to WakaTime release naming */ function getWakaTimePlatform(): string | null { switch (process.platform) { 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..a2c63b9a7 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; From 04512c2c8829521d2128fdcf6bef9077e20b8cba Mon Sep 17 00:00:00 2001 From: Kian Date: Mon, 23 Feb 2026 20:39:44 -0600 Subject: [PATCH 02/12] feat: add write-tool detection and file-path extraction for WakaTime file heartbeats Add WRITE_TOOL_NAMES set (Write, Edit, write_to_file, str_replace_based_edit_tool, create_file, write, patch, NotebookEdit) and extractFilePathFromToolExecution() function that inspects tool-execution events and extracts file paths from input.file_path or input.path fields. Supports Claude Code, Codex, and OpenCode agent tool naming conventions. --- src/__tests__/main/wakatime-manager.test.ts | 124 +++++++++++++++++++- src/main/wakatime-manager.ts | 32 +++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts index 8551030b5..253e32d63 100644 --- a/src/__tests__/main/wakatime-manager.test.ts +++ b/src/__tests__/main/wakatime-manager.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WakaTimeManager, detectLanguageFromPath } from '../../main/wakatime-manager'; +import { WakaTimeManager, detectLanguageFromPath, WRITE_TOOL_NAMES, extractFilePathFromToolExecution } from '../../main/wakatime-manager'; // Mock electron vi.mock('electron', () => ({ @@ -674,6 +674,128 @@ describe('WakaTimeManager', () => { }); }); + 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'); diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts index d4a7508af..20f7bcfab 100644 --- a/src/main/wakatime-manager.ts +++ b/src/main/wakatime-manager.ts @@ -87,6 +87,38 @@ export function detectLanguageFromPath(filePath: string): string | 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) { From 0211890bf6896083400e8e51180f9fdeda70298d Mon Sep 17 00:00:00 2001 From: Kian Date: Mon, 23 Feb 2026 20:49:06 -0600 Subject: [PATCH 03/12] feat: add sendFileHeartbeats method for batch file-level WakaTime heartbeats Add public async method to WakaTimeManager that sends file-level heartbeats collected from tool executions. The first file is sent as the primary heartbeat via CLI args; remaining files are batched via --extra-heartbeats on stdin as a JSON array. Includes language detection per file, branch detection, and gating on both wakatimeEnabled and wakatimeDetailedTracking settings. 12 new tests cover all code paths. --- src/__tests__/main/wakatime-manager.test.ts | 292 ++++++++++++++++++++ src/main/wakatime-manager.ts | 88 ++++++ 2 files changed, 380 insertions(+) diff --git a/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts index 253e32d63..ee2fdbcfd 100644 --- a/src/__tests__/main/wakatime-manager.test.ts +++ b/src/__tests__/main/wakatime-manager.test.ts @@ -879,4 +879,296 @@ describe('WakaTimeManager', () => { 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 correct CLI args', 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', 'coding', + '--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 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('coding'); + 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 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/wakatime-manager.ts b/src/main/wakatime-manager.ts index 20f7bcfab..e1a4ddcf6 100644 --- a/src/main/wakatime-manager.ts +++ b/src/main/wakatime-manager.ts @@ -590,6 +590,94 @@ 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 + ): 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())) return; + + const branch = projectCwd + ? await this.detectBranch('file-heartbeat', 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', + 'coding', + '--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: 'coding', + project: projectName, + }; + const lang = detectLanguageFromPath(f.filePath); + if (lang) obj.language = lang; + if (branch) obj.branch = branch; + return obj; + }); + + await execFileNoThrow( + this.cliPath!, + args, + projectCwd, + extraFiles.length > 0 ? { input: JSON.stringify(extraArray) } : undefined + ); + + logger.info('Sent file heartbeats', LOG_CONTEXT, { count: files.length }); + } + /** Get the resolved CLI path (null if not yet detected/installed) */ getCliPath(): string | null { return this.cliPath; From 8d73b9bed0ed84f62938267aca446849e606bbe5 Mon Sep 17 00:00:00 2001 From: Kian Date: Mon, 23 Feb 2026 20:54:18 -0600 Subject: [PATCH 04/12] feat: wire tool-execution collection and query-complete flush in WakaTime listener Add per-session file path accumulation from tool-execution events and flush as file-level heartbeats on query-complete. Controlled by the wakatimeDetailedTracking setting. Pending files are cleaned up on exit to prevent memory leaks. --- .../__tests__/wakatime-listener.test.ts | 303 +++++++++++++++++- .../process-listeners/wakatime-listener.ts | 49 ++- 2 files changed, 348 insertions(+), 4 deletions(-) diff --git a/src/main/process-listeners/__tests__/wakatime-listener.test.ts b/src/main/process-listeners/__tests__/wakatime-listener.test.ts index 40ca7f410..950293e8f 100644 --- a/src/main/process-listeners/__tests__/wakatime-listener.test.ts +++ b/src/main/process-listeners/__tests__/wakatime-listener.test.ts @@ -2,7 +2,8 @@ * 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, + * and exit events clean up sessions and pending file data. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -32,6 +33,7 @@ describe('WakaTime Listener', () => { mockWakaTimeManager = { sendHeartbeat: vi.fn().mockResolvedValue(undefined), + sendFileHeartbeats: vi.fn().mockResolvedValue(undefined), removeSession: vi.fn(), } as unknown as WakaTimeManager; @@ -44,11 +46,12 @@ 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, 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('exit', expect.any(Function)); }); @@ -294,4 +297,300 @@ 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' + ); + }); + + 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' + ); + }); + + 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' + ); + }); + + 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' + ); + }); + + 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(); + }); + }); }); diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts index 78ced7e79..f01c76a06 100644 --- a/src/main/process-listeners/wakatime-listener.ts +++ b/src/main/process-listeners/wakatime-listener.ts @@ -7,13 +7,17 @@ * 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`. */ 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 } 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 */ @@ -46,6 +50,16 @@ 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 as boolean; + }); + + // Per-session accumulator for file paths from tool-execution events. + // Outer key: sessionId, inner key: filePath (deduplicates, keeping latest timestamp). + const pendingFiles = new Map>(); + // Send heartbeat on any AI output (covers interactive sessions) // The 2-minute debounce in WakaTimeManager prevents flooding processManager.on('data', (sessionId: string) => { @@ -60,6 +74,21 @@ 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; @@ -67,10 +96,26 @@ export function setupWakaTimeListener( ? path.basename(queryData.projectPath) : queryData.sessionId; void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath); + + // Flush accumulated file heartbeats + if (!detailedEnabled) return; + const sessionFiles = pendingFiles.get(queryData.sessionId); + if (!sessionFiles || sessionFiles.size === 0) return; + + const filesArray = Array.from(sessionFiles.values()).map((f) => ({ + filePath: path.isAbsolute(f.filePath) + ? f.filePath + : path.resolve(queryData.projectPath || '', f.filePath), + timestamp: f.timestamp, + })); + + void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, queryData.projectPath); + pendingFiles.delete(queryData.sessionId); }); - // Clean up debounce tracking when a process exits + // Clean up debounce tracking and pending file data when a process exits processManager.on('exit', (sessionId: string) => { wakaTimeManager.removeSession(sessionId); + pendingFiles.delete(sessionId); }); } From 63f0cd84494679c108dced3473a9bee5ffa55fd8 Mon Sep 17 00:00:00 2001 From: Kian Date: Mon, 23 Feb 2026 20:56:16 -0600 Subject: [PATCH 05/12] feat: add detailed file tracking toggle to WakaTime settings UI --- src/renderer/components/SettingsModal.tsx | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 8e47f9259..9ff87d2b1 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,40 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro

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

+ Detailed file tracking +

+

+ Send file paths for write operations to WakaTime for per-file time tracking. File paths (not content) are sent to WakaTime servers. +

+
+ +
+ )} + {/* API Key Input (only shown when enabled) */} {wakatimeEnabled && (
From 2e743c029b8ee45d573b47f3e99527da28c68ba0 Mon Sep 17 00:00:00 2001 From: Kian Date: Tue, 24 Feb 2026 11:14:33 -0600 Subject: [PATCH 06/12] docs: add WakaTime integration docs and fix settings toggle styling Add WakaTime section to configuration.md covering setup, detailed file tracking, and per-agent supported tools. Add wakatime namespace to CLAUDE-IPC.md and feature bullet to features.md. Fix detailed file tracking toggle padding and shorten description to one line. --- CLAUDE-IPC.md | 4 +++ docs/configuration.md | 37 +++++++++++++++++++++++ docs/features.md | 1 + src/renderer/components/SettingsModal.tsx | 4 +-- 4 files changed, 44 insertions(+), 2 deletions(-) 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..569735ec6 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,42 @@ 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. + ## 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/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 9ff87d2b1..7b5e43cc8 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -2204,7 +2204,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro {/* Detailed file tracking toggle (only shown when enabled) */} {wakatimeEnabled && ( -
+

- Send file paths for write operations to WakaTime for per-file time tracking. File paths (not content) are sent to WakaTime servers. + Track per-file write activity. Sends file paths (not content) to WakaTime.