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.
+
+
+
setWakatimeDetailedTracking(!wakatimeDetailedTracking)}
+ className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
+ style={{
+ backgroundColor: wakatimeDetailedTracking
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ role="switch"
+ aria-checked={wakatimeDetailedTracking}
+ >
+
+
+
+ )}
+
{/* 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.
Date: Tue, 24 Feb 2026 11:52:38 -0600
Subject: [PATCH 07/12] feat: flush file heartbeats on usage event for
interactive sessions
File heartbeats were only flushed on query-complete (batch/auto-run).
Interactive chat sessions accumulated file paths but never sent them.
Add a usage event handler with 500ms per-session debounce that flushes
accumulated file heartbeats at end-of-turn for all session types.
Extract shared flushPendingFiles() helper. query-complete cancels any
pending usage timer to prevent double-flush.
---
.../__tests__/wakatime-listener.test.ts | 272 +++++++++++++++++-
.../process-listeners/wakatime-listener.ts | 84 +++++-
2 files changed, 339 insertions(+), 17 deletions(-)
diff --git a/src/main/process-listeners/__tests__/wakatime-listener.test.ts b/src/main/process-listeners/__tests__/wakatime-listener.test.ts
index 950293e8f..4b52ef1fe 100644
--- a/src/main/process-listeners/__tests__/wakatime-listener.test.ts
+++ b/src/main/process-listeners/__tests__/wakatime-listener.test.ts
@@ -3,10 +3,11 @@
* Verifies that data and thinking-chunk events trigger heartbeats for interactive sessions,
* query-complete events trigger heartbeats for batch/auto-run,
* 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';
@@ -46,13 +47,14 @@ describe('WakaTime Listener', () => {
};
});
- it('should register data, thinking-chunk, tool-execution, 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));
});
@@ -593,4 +595,270 @@ describe('WakaTime Listener', () => {
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'
+ );
+ });
+
+ 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'
+ );
+ });
+
+ 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'
+ );
+ });
+
+ 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'
+ );
+ });
+
+ 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 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 f01c76a06..3f200ed3d 100644
--- a/src/main/process-listeners/wakatime-listener.ts
+++ b/src/main/process-listeners/wakatime-listener.ts
@@ -9,13 +9,14 @@
* 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`.
+ * 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, ToolExecution } 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';
@@ -33,9 +34,12 @@ function heartbeatForSession(
void wakaTimeManager.sendHeartbeat(sessionId, projectName, projectDir);
}
+/** 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(
@@ -60,6 +64,25 @@ export function setupWakaTimeListener(
// Outer key: sessionId, inner key: filePath (deduplicates, keeping latest timestamp).
const pendingFiles = new Map>();
+ // Per-session debounce timers for usage-based file flush.
+ const usageFlushTimers = new Map>();
+
+ /** Flush accumulated file heartbeats for a session. */
+ function flushPendingFiles(sessionId: string, projectDir: string | undefined, projectName: string): void {
+ const sessionFiles = pendingFiles.get(sessionId);
+ if (!sessionFiles || sessionFiles.size === 0) return;
+
+ const filesArray = Array.from(sessionFiles.values()).map((f) => ({
+ filePath: path.isAbsolute(f.filePath)
+ ? f.filePath
+ : path.resolve(projectDir || '', f.filePath),
+ timestamp: f.timestamp,
+ }));
+
+ void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, projectDir);
+ pendingFiles.delete(sessionId);
+ }
+
// Send heartbeat on any AI output (covers interactive sessions)
// The 2-minute debounce in WakaTimeManager prevents flooding
processManager.on('data', (sessionId: string) => {
@@ -98,24 +121,55 @@ export function setupWakaTimeListener(
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;
+ if (detailedEnabled) {
+ flushPendingFiles(queryData.sessionId, queryData.projectPath, projectName);
+ }
- const filesArray = Array.from(sessionFiles.values()).map((f) => ({
- filePath: path.isAbsolute(f.filePath)
- ? f.filePath
- : path.resolve(queryData.projectPath || '', f.filePath),
- timestamp: f.timestamp,
- }));
+ // Cancel any pending usage-based flush since query-complete already flushed
+ const timer = usageFlushTimers.get(queryData.sessionId);
+ if (timer) {
+ clearTimeout(timer);
+ usageFlushTimers.delete(queryData.sessionId);
+ }
+ });
+
+ // Flush accumulated file heartbeats on usage events (fires for ALL sessions,
+ // including interactive). Debounced per-session since usage can fire multiple
+ // times per turn.
+ processManager.on('usage', (sessionId: string, _usageStats: UsageStats) => {
+ if (!enabled || !detailedEnabled) return;
+ if (!pendingFiles.has(sessionId) || pendingFiles.get(sessionId)!.size === 0) return;
+
+ // Reset existing timer for this session (debounce)
+ const existingTimer = usageFlushTimers.get(sessionId);
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+
+ usageFlushTimers.set(
+ sessionId,
+ setTimeout(() => {
+ usageFlushTimers.delete(sessionId);
- void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, queryData.projectPath);
- pendingFiles.delete(queryData.sessionId);
+ const managedProcess = processManager.get(sessionId);
+ if (!managedProcess || managedProcess.isTerminal) return;
+
+ const projectDir = managedProcess.projectPath || managedProcess.cwd;
+ const projectName = projectDir ? path.basename(projectDir) : sessionId;
+
+ flushPendingFiles(sessionId, projectDir, projectName);
+ }, USAGE_FLUSH_DELAY_MS)
+ );
});
- // Clean up debounce tracking and pending file data when a process exits
+ // Clean up debounce tracking, pending file data, and flush timers on exit
processManager.on('exit', (sessionId: string) => {
wakaTimeManager.removeSession(sessionId);
pendingFiles.delete(sessionId);
+ const timer = usageFlushTimers.get(sessionId);
+ if (timer) {
+ clearTimeout(timer);
+ usageFlushTimers.delete(sessionId);
+ }
});
}
From a90569f77a9f4961b01e5d7c6510809761433a6b Mon Sep 17 00:00:00 2001
From: Kian
Date: Tue, 24 Feb 2026 16:09:01 -0600
Subject: [PATCH 08/12] fix: don't permanently cache branch detection failures
in WakaTime heartbeats
Failed git lookups are no longer cached, so transient failures retry on
the next heartbeat instead of suppressing the branch field for the entire
session. Successful results expire after 5 minutes to pick up branch
switches. File-level heartbeats now cache per project directory instead
of sharing a single key across all sessions.
---
src/main/wakatime-manager.ts | 21 ++++++++++++++++-----
1 file changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts
index e1a4ddcf6..302e8ce33 100644
--- a/src/main/wakatime-manager.ts
+++ b/src/main/wakatime-manager.ts
@@ -235,10 +235,13 @@ function fetchJson(url: string, maxRedirects = 5): Promise {
});
}
+/** How long a successfully-detected branch is cached before re-checking (5 min). */
+const BRANCH_CACHE_TTL_MS = 5 * 60 * 1000;
+
export class WakaTimeManager {
private settingsStore: Store;
private lastHeartbeatPerSession: Map = new Map();
- private branchCache: Map = new Map();
+ private branchCache: Map = new Map();
private languageCache: Map = new Map();
private cliPath: string | null = null;
private cliDetected = false;
@@ -514,15 +517,23 @@ export class WakaTimeManager {
/**
* Detect the current git branch for a project directory.
- * Result is cached per session to avoid running git on every heartbeat.
+ * Successful results are cached per session with a TTL so branch switches
+ * are picked up. Failures are never cached — the next heartbeat retries.
*/
private async detectBranch(sessionId: string, cwd: string): Promise {
const cached = this.branchCache.get(sessionId);
- if (cached !== undefined) return cached || null;
+ if (cached && cached.branch && Date.now() - cached.timestamp < BRANCH_CACHE_TTL_MS) {
+ return cached.branch;
+ }
const result = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
const branch = result.exitCode === 0 ? result.stdout.trim() : '';
- this.branchCache.set(sessionId, branch);
+ if (branch) {
+ this.branchCache.set(sessionId, { branch, timestamp: Date.now() });
+ } else {
+ // Don't cache failures — retry on next heartbeat
+ this.branchCache.delete(sessionId);
+ }
return branch || null;
}
@@ -617,7 +628,7 @@ export class WakaTimeManager {
if (!(await this.ensureCliInstalled())) return;
const branch = projectCwd
- ? await this.detectBranch('file-heartbeat', projectCwd)
+ ? await this.detectBranch(`file:${projectCwd}`, projectCwd)
: null;
const primary = files[0];
From 4a4c4e303c18ee3951511d87ec7fa729bbf847d0 Mon Sep 17 00:00:00 2001
From: Kian
Date: Tue, 24 Feb 2026 16:24:36 -0600
Subject: [PATCH 09/12] fix: address PR review feedback for WakaTime file
heartbeats
- Check execFileNoThrow exit code and log warn on failure
- Add tabIndex and outline-none to detailed tracking toggle
- Export setWakatimeDetailedTracking from getSettingsActions()
- Use !!val for consistent boolean coercion in listener
---
src/main/process-listeners/wakatime-listener.ts | 2 +-
src/main/wakatime-manager.ts | 8 ++++++--
src/renderer/components/SettingsModal.tsx | 3 ++-
src/renderer/stores/settingsStore.ts | 1 +
4 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts
index 3f200ed3d..b5e76c4f2 100644
--- a/src/main/process-listeners/wakatime-listener.ts
+++ b/src/main/process-listeners/wakatime-listener.ts
@@ -57,7 +57,7 @@ export function setupWakaTimeListener(
// 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;
+ detailedEnabled = !!val;
});
// Per-session accumulator for file paths from tool-execution events.
diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts
index 302e8ce33..a750a8f87 100644
--- a/src/main/wakatime-manager.ts
+++ b/src/main/wakatime-manager.ts
@@ -679,14 +679,18 @@ export class WakaTimeManager {
return obj;
});
- await execFileNoThrow(
+ const result = await execFileNoThrow(
this.cliPath!,
args,
projectCwd,
extraFiles.length > 0 ? { input: JSON.stringify(extraArray) } : undefined
);
- logger.info('Sent file heartbeats', LOG_CONTEXT, { count: files.length });
+ if (result.exitCode === 0) {
+ logger.info('Sent file heartbeats', LOG_CONTEXT, { count: files.length });
+ } else {
+ logger.warn(`File heartbeats failed: ${result.stderr}`, LOG_CONTEXT, { count: files.length });
+ }
}
/** Get the resolved CLI path (null if not yet detected/installed) */
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index 7b5e43cc8..002c94332 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -2218,7 +2218,8 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
setWakatimeDetailedTracking(!wakatimeDetailedTracking)}
- className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
+ className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0 outline-none"
+ tabIndex={0}
style={{
backgroundColor: wakatimeDetailedTracking
? theme.colors.accent
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts
index a2c63b9a7..c10e6071e 100644
--- a/src/renderer/stores/settingsStore.ts
+++ b/src/renderer/stores/settingsStore.ts
@@ -1806,6 +1806,7 @@ export function getSettingsActions() {
setDirectorNotesSettings: state.setDirectorNotesSettings,
setWakatimeApiKey: state.setWakatimeApiKey,
setWakatimeEnabled: state.setWakatimeEnabled,
+ setWakatimeDetailedTracking: state.setWakatimeDetailedTracking,
setUseNativeTitleBar: state.setUseNativeTitleBar,
setAutoHideMenuBar: state.setAutoHideMenuBar,
};
From 2b297b2a75f62ede1d1db68d60a38c6eed911dbb Mon Sep 17 00:00:00 2001
From: Kian
Date: Wed, 25 Feb 2026 06:24:05 +0000
Subject: [PATCH 10/12] feat: set WakaTime category based on session mode (user
vs auto)
Thread querySource/source through WakaTime heartbeats so the category
reflects how the session was initiated: interactive (user) sessions
send 'building', auto-run/batch sessions send 'ai coding'.
https://claude.ai/code/session_01FR9j7wLSUgaS4WvoC7JM2X
---
docs/configuration.md | 9 ++
src/__tests__/main/wakatime-manager.test.ts | 100 +++++++++++-
.../__tests__/wakatime-listener.test.ts | 142 ++++++++++++++++--
.../process-listeners/wakatime-listener.ts | 12 +-
src/main/wakatime-manager.ts | 11 +-
5 files changed, 243 insertions(+), 31 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 569735ec6..ce9d91a55 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -300,6 +300,15 @@ File heartbeats are generated for write operations across all supported agents:
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/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts
index ee2fdbcfd..5c4dbff03 100644
--- a/src/__tests__/main/wakatime-manager.test.ts
+++ b/src/__tests__/main/wakatime-manager.test.ts
@@ -332,7 +332,7 @@ describe('WakaTimeManager', () => {
'--plugin',
'maestro/1.0.0 maestro-wakatime/1.0.0',
'--category',
- 'ai coding',
+ 'building',
]);
});
@@ -422,7 +422,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 +450,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 +458,30 @@ 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: '' })
@@ -950,7 +974,7 @@ describe('WakaTimeManager', () => {
expect(execFileNoThrow).not.toHaveBeenCalled();
});
- it('should send a single file heartbeat with correct CLI args', async () => {
+ it('should send a single file heartbeat with building category by default', async () => {
// The heartbeat exec call
vi.mocked(execFileNoThrow).mockResolvedValueOnce({
exitCode: 0,
@@ -973,7 +997,7 @@ describe('WakaTimeManager', () => {
'--write',
'--project', 'My Project',
'--plugin', 'maestro/1.0.0 maestro-wakatime/1.0.0',
- '--category', 'coding',
+ '--category', 'building',
'--time', String(1708700000000 / 1000),
'--language', 'TypeScript',
]);
@@ -983,6 +1007,46 @@ describe('WakaTimeManager', () => {
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({
@@ -1019,13 +1083,37 @@ describe('WakaTimeManager', () => {
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].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) => {
diff --git a/src/main/process-listeners/__tests__/wakatime-listener.test.ts b/src/main/process-listeners/__tests__/wakatime-listener.test.ts
index 4b52ef1fe..a705ae927 100644
--- a/src/main/process-listeners/__tests__/wakatime-listener.test.ts
+++ b/src/main/process-listeners/__tests__/wakatime-listener.test.ts
@@ -77,7 +77,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith(
'session-abc',
'project',
- '/home/user/project'
+ '/home/user/project',
+ undefined
);
});
@@ -100,7 +101,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith(
'session-thinking',
'project',
- '/home/user/project'
+ '/home/user/project',
+ undefined
);
});
@@ -151,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');
@@ -174,7 +177,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendHeartbeat).toHaveBeenCalledWith(
'session-abc',
'project',
- '/home/user/project'
+ '/home/user/project',
+ 'user'
);
});
@@ -195,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'
);
});
@@ -347,7 +400,32 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/project/src/index.ts', timestamp: 1000 }],
'project',
- '/home/user/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'
);
});
@@ -394,7 +472,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/project/src/app.ts', timestamp: 2000 }],
'project',
- '/home/user/project'
+ '/home/user/project',
+ 'user'
);
});
@@ -417,7 +496,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/project/src/utils.ts', timestamp: 1000 }],
'project',
- '/home/user/project'
+ '/home/user/project',
+ 'user'
);
});
@@ -440,7 +520,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/absolute/path/file.ts', timestamp: 1000 }],
'project',
- '/home/user/project'
+ '/home/user/project',
+ 'user'
);
});
@@ -661,7 +742,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/project/src/app.ts', timestamp: 1000 }],
'project',
- '/home/user/project'
+ '/home/user/project',
+ undefined
);
});
@@ -701,7 +783,8 @@ describe('WakaTime Listener', () => {
{ filePath: '/home/user/project/b.ts', timestamp: 2000 },
]),
'project',
- '/home/user/project'
+ '/home/user/project',
+ undefined
);
});
@@ -792,7 +875,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/project/src/utils.ts', timestamp: 1000 }],
'project',
- '/home/user/project'
+ '/home/user/project',
+ undefined
);
});
@@ -818,7 +902,8 @@ describe('WakaTime Listener', () => {
expect(mockWakaTimeManager.sendFileHeartbeats).toHaveBeenCalledWith(
[{ filePath: '/home/user/fallback/src/utils.ts', timestamp: 1000 }],
'fallback',
- '/home/user/fallback'
+ '/home/user/fallback',
+ undefined
);
});
@@ -839,6 +924,35 @@ describe('WakaTime Listener', () => {
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',
diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts
index b5e76c4f2..92cf7105b 100644
--- a/src/main/process-listeners/wakatime-listener.ts
+++ b/src/main/process-listeners/wakatime-listener.ts
@@ -31,7 +31,7 @@ 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) */
@@ -68,7 +68,7 @@ export function setupWakaTimeListener(
const usageFlushTimers = new Map>();
/** Flush accumulated file heartbeats for a session. */
- function flushPendingFiles(sessionId: string, projectDir: string | undefined, projectName: string): void {
+ function flushPendingFiles(sessionId: string, projectDir: string | undefined, projectName: string, source?: 'user' | 'auto'): void {
const sessionFiles = pendingFiles.get(sessionId);
if (!sessionFiles || sessionFiles.size === 0) return;
@@ -79,7 +79,7 @@ export function setupWakaTimeListener(
timestamp: f.timestamp,
}));
- void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, projectDir);
+ void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, projectDir, source);
pendingFiles.delete(sessionId);
}
@@ -118,11 +118,11 @@ export function setupWakaTimeListener(
const projectName = queryData.projectPath
? path.basename(queryData.projectPath)
: queryData.sessionId;
- void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath);
+ void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath, queryData.source);
// Flush accumulated file heartbeats
if (detailedEnabled) {
- flushPendingFiles(queryData.sessionId, queryData.projectPath, projectName);
+ flushPendingFiles(queryData.sessionId, queryData.projectPath, projectName, queryData.source);
}
// Cancel any pending usage-based flush since query-complete already flushed
@@ -157,7 +157,7 @@ export function setupWakaTimeListener(
const projectDir = managedProcess.projectPath || managedProcess.cwd;
const projectName = projectDir ? path.basename(projectDir) : sessionId;
- flushPendingFiles(sessionId, projectDir, projectName);
+ flushPendingFiles(sessionId, projectDir, projectName, managedProcess.querySource);
}, USAGE_FLUSH_DELAY_MS)
);
});
diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts
index a750a8f87..abc3de45f 100644
--- a/src/main/wakatime-manager.ts
+++ b/src/main/wakatime-manager.ts
@@ -538,7 +538,7 @@ export class WakaTimeManager {
}
/** Send a heartbeat for a session's activity */
- async sendHeartbeat(sessionId: string, projectName: string, projectCwd?: string): Promise {
+ async sendHeartbeat(sessionId: string, projectName: string, projectCwd?: string, source?: 'user' | 'auto'): Promise {
// Check if enabled
const enabled = this.settingsStore.get('wakatimeEnabled', false);
if (!enabled) return;
@@ -574,7 +574,7 @@ export class WakaTimeManager {
'--plugin',
`maestro/${app.getVersion()} maestro-wakatime/${app.getVersion()}`,
'--category',
- 'ai coding',
+ source === 'auto' ? 'ai coding' : 'building',
];
// Detect project language from manifest files in cwd
@@ -609,7 +609,8 @@ export class WakaTimeManager {
async sendFileHeartbeats(
files: Array<{ filePath: string; timestamp: number }>,
projectName: string,
- projectCwd?: string
+ projectCwd?: string,
+ source?: 'user' | 'auto'
): Promise {
if (files.length === 0) return;
@@ -645,7 +646,7 @@ export class WakaTimeManager {
'--plugin',
`maestro/${app.getVersion()} maestro-wakatime/${app.getVersion()}`,
'--category',
- 'coding',
+ source === 'auto' ? 'ai coding' : 'building',
'--time',
String(primary.timestamp / 1000),
];
@@ -670,7 +671,7 @@ export class WakaTimeManager {
type: 'file',
is_write: true,
time: f.timestamp / 1000,
- category: 'coding',
+ category: source === 'auto' ? 'ai coding' : 'building',
project: projectName,
};
const lang = detectLanguageFromPath(f.filePath);
From 24043306b259899aa48568a115830e82e14c9576 Mon Sep 17 00:00:00 2001
From: Kian
Date: Wed, 25 Feb 2026 12:48:59 -0600
Subject: [PATCH 11/12] fix: address remaining PR review feedback for WakaTime
file heartbeats
- Skip relative file paths when projectDir is undefined instead of
resolving against process.cwd() (app install dir)
- Add warning log when CLI is unavailable in sendFileHeartbeats
- Clear pending files when detailed tracking is toggled off to prevent
stale paths leaking across turns
---
.../process-listeners/wakatime-listener.ts | 24 ++++++++++++-------
src/main/wakatime-manager.ts | 5 +++-
2 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts
index 92cf7105b..926e822b0 100644
--- a/src/main/process-listeners/wakatime-listener.ts
+++ b/src/main/process-listeners/wakatime-listener.ts
@@ -72,12 +72,14 @@ export function setupWakaTimeListener(
const sessionFiles = pendingFiles.get(sessionId);
if (!sessionFiles || sessionFiles.size === 0) return;
- const filesArray = Array.from(sessionFiles.values()).map((f) => ({
- filePath: path.isAbsolute(f.filePath)
- ? f.filePath
- : path.resolve(projectDir || '', f.filePath),
- timestamp: f.timestamp,
- }));
+ const filesArray = Array.from(sessionFiles.values())
+ .map((f) => ({
+ filePath: path.isAbsolute(f.filePath)
+ ? f.filePath
+ : projectDir ? path.resolve(projectDir, f.filePath) : null,
+ timestamp: f.timestamp,
+ }))
+ .filter((f): f is { filePath: string; timestamp: number } => f.filePath !== null);
void wakaTimeManager.sendFileHeartbeats(filesArray, projectName, projectDir, source);
pendingFiles.delete(sessionId);
@@ -120,9 +122,11 @@ export function setupWakaTimeListener(
: queryData.sessionId;
void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath, queryData.source);
- // Flush accumulated file heartbeats
+ // Flush accumulated file heartbeats (or clear if detailed tracking was disabled)
if (detailedEnabled) {
flushPendingFiles(queryData.sessionId, queryData.projectPath, projectName, queryData.source);
+ } else {
+ pendingFiles.delete(queryData.sessionId);
}
// Cancel any pending usage-based flush since query-complete already flushed
@@ -137,7 +141,11 @@ export function setupWakaTimeListener(
// including interactive). Debounced per-session since usage can fire multiple
// times per turn.
processManager.on('usage', (sessionId: string, _usageStats: UsageStats) => {
- if (!enabled || !detailedEnabled) return;
+ if (!enabled) return;
+ if (!detailedEnabled) {
+ pendingFiles.delete(sessionId);
+ return;
+ }
if (!pendingFiles.has(sessionId) || pendingFiles.get(sessionId)!.size === 0) return;
// Reset existing timer for this session (debounce)
diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts
index abc3de45f..4ec1ff89c 100644
--- a/src/main/wakatime-manager.ts
+++ b/src/main/wakatime-manager.ts
@@ -626,7 +626,10 @@ export class WakaTimeManager {
}
if (!apiKey) return;
- if (!(await this.ensureCliInstalled())) return;
+ if (!(await this.ensureCliInstalled())) {
+ logger.warn('WakaTime CLI not available — skipping file heartbeats', LOG_CONTEXT);
+ return;
+ }
const branch = projectCwd
? await this.detectBranch(`file:${projectCwd}`, projectCwd)
From 778cc8bfea41c6cc887264365593792970237d1e Mon Sep 17 00:00:00 2001
From: Kian
Date: Wed, 25 Feb 2026 15:37:49 -0600
Subject: [PATCH 12/12] fix: linting
---
src/__tests__/main/wakatime-manager.test.ts | 93 ++++++++++++-------
.../process-listeners/wakatime-listener.ts | 25 ++++-
src/main/wakatime-manager.ts | 11 ++-
src/renderer/components/SettingsModal.tsx | 5 +-
4 files changed, 86 insertions(+), 48 deletions(-)
diff --git a/src/__tests__/main/wakatime-manager.test.ts b/src/__tests__/main/wakatime-manager.test.ts
index 5c4dbff03..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, detectLanguageFromPath, WRITE_TOOL_NAMES, extractFilePathFromToolExecution } from '../../main/wakatime-manager';
+import {
+ WakaTimeManager,
+ detectLanguageFromPath,
+ WRITE_TOOL_NAMES,
+ extractFilePathFromToolExecution,
+} from '../../main/wakatime-manager';
// Mock electron
vi.mock('electron', () => ({
@@ -465,7 +470,8 @@ describe('WakaTimeManager', () => {
await manager.sendHeartbeat('session-1', 'My Project', undefined, 'auto');
- expect(execFileNoThrow).toHaveBeenCalledWith('wakatime-cli',
+ expect(execFileNoThrow).toHaveBeenCalledWith(
+ 'wakatime-cli',
expect.arrayContaining(['--category', 'ai coding'])
);
});
@@ -477,7 +483,8 @@ describe('WakaTimeManager', () => {
await manager.sendHeartbeat('session-1', 'My Project', undefined, 'user');
- expect(execFileNoThrow).toHaveBeenCalledWith('wakatime-cli',
+ expect(execFileNoThrow).toHaveBeenCalledWith(
+ 'wakatime-cli',
expect.arrayContaining(['--category', 'building'])
);
});
@@ -701,8 +708,14 @@ 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',
+ '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);
@@ -991,15 +1004,23 @@ describe('WakaTimeManager', () => {
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',
+ '--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',
+ '--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');
@@ -1116,15 +1137,17 @@ describe('WakaTimeManager', () => {
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: '' };
- });
+ 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 }],
@@ -1141,15 +1164,17 @@ describe('WakaTimeManager', () => {
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: '' };
- });
+ 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 },
@@ -1200,11 +1225,7 @@ describe('WakaTimeManager', () => {
'My Project'
);
- expect(logger.info).toHaveBeenCalledWith(
- 'Sent file heartbeats',
- '[WakaTime]',
- { count: 2 }
- );
+ expect(logger.info).toHaveBeenCalledWith('Sent file heartbeats', '[WakaTime]', { count: 2 });
});
it('should convert timestamps to seconds for WakaTime CLI', async () => {
diff --git a/src/main/process-listeners/wakatime-listener.ts b/src/main/process-listeners/wakatime-listener.ts
index 926e822b0..e6f29886c 100644
--- a/src/main/process-listeners/wakatime-listener.ts
+++ b/src/main/process-listeners/wakatime-listener.ts
@@ -31,7 +31,12 @@ 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, managedProcess.querySource);
+ void wakaTimeManager.sendHeartbeat(
+ sessionId,
+ projectName,
+ projectDir,
+ managedProcess.querySource
+ );
}
/** Debounce delay for flushing file heartbeats after a `usage` event (ms) */
@@ -68,7 +73,12 @@ export function setupWakaTimeListener(
const usageFlushTimers = new Map>();
/** Flush accumulated file heartbeats for a session. */
- function flushPendingFiles(sessionId: string, projectDir: string | undefined, projectName: string, source?: 'user' | 'auto'): void {
+ function flushPendingFiles(
+ sessionId: string,
+ projectDir: string | undefined,
+ projectName: string,
+ source?: 'user' | 'auto'
+ ): void {
const sessionFiles = pendingFiles.get(sessionId);
if (!sessionFiles || sessionFiles.size === 0) return;
@@ -76,7 +86,9 @@ export function setupWakaTimeListener(
.map((f) => ({
filePath: path.isAbsolute(f.filePath)
? f.filePath
- : projectDir ? path.resolve(projectDir, f.filePath) : null,
+ : projectDir
+ ? path.resolve(projectDir, f.filePath)
+ : null,
timestamp: f.timestamp,
}))
.filter((f): f is { filePath: string; timestamp: number } => f.filePath !== null);
@@ -120,7 +132,12 @@ export function setupWakaTimeListener(
const projectName = queryData.projectPath
? path.basename(queryData.projectPath)
: queryData.sessionId;
- void wakaTimeManager.sendHeartbeat(queryData.sessionId, projectName, queryData.projectPath, queryData.source);
+ void wakaTimeManager.sendHeartbeat(
+ queryData.sessionId,
+ projectName,
+ queryData.projectPath,
+ queryData.source
+ );
// Flush accumulated file heartbeats (or clear if detailed tracking was disabled)
if (detailedEnabled) {
diff --git a/src/main/wakatime-manager.ts b/src/main/wakatime-manager.ts
index 4ec1ff89c..8edf52936 100644
--- a/src/main/wakatime-manager.ts
+++ b/src/main/wakatime-manager.ts
@@ -538,7 +538,12 @@ export class WakaTimeManager {
}
/** Send a heartbeat for a session's activity */
- async sendHeartbeat(sessionId: string, projectName: string, projectCwd?: string, source?: 'user' | 'auto'): Promise {
+ async sendHeartbeat(
+ sessionId: string,
+ projectName: string,
+ projectCwd?: string,
+ source?: 'user' | 'auto'
+ ): Promise {
// Check if enabled
const enabled = this.settingsStore.get('wakatimeEnabled', false);
if (!enabled) return;
@@ -631,9 +636,7 @@ export class WakaTimeManager {
return;
}
- const branch = projectCwd
- ? await this.detectBranch(`file:${projectCwd}`, projectCwd)
- : null;
+ const branch = projectCwd ? await this.detectBranch(`file:${projectCwd}`, projectCwd) : null;
const primary = files[0];
const args = [
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index 002c94332..e67f7b9ea 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -2206,10 +2206,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
{wakatimeEnabled && (
-
+
Detailed file tracking