Files
TaskTracker/docs/plans/2026-02-27-idle-detection-plan.md
2026-02-27 00:08:55 -05:00

7.0 KiB

Idle Detection Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Auto-pause the active TaskTracker task when the user is idle, and auto-resume it when they return.

Architecture: Add GetLastInputInfo P/Invoke to NativeMethods.cs, check idle time on every existing poll cycle in Worker.cs, and call the TaskTracker pause/resume API on state transitions. No new threads, timers, or services needed.

Tech Stack: .NET 10, Win32 user32.dll P/Invoke, existing IHttpClientFactory


Task 1: Add GetLastInputInfo to NativeMethods

Files:

  • Modify: WindowWatcher/NativeMethods.cs

Step 1: Add the LASTINPUTINFO struct and GetLastInputInfo import

Add the following to NativeMethods.cs:

[StructLayout(LayoutKind.Sequential)]
internal struct LASTINPUTINFO
{
    public uint cbSize;
    public uint dwTime;
}

[DllImport("user32.dll")]
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);

Note: GetLastInputInfo must use DllImport, not LibraryImport, because it takes a ref struct with cbSize that needs manual marshalling.

Step 2: Add a helper method for getting idle time

internal static TimeSpan GetIdleTime()
{
    var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
    if (!GetLastInputInfo(ref info))
        return TimeSpan.Zero;
    return TimeSpan.FromMilliseconds(Environment.TickCount64 - info.dwTime);
}

Step 3: Commit

git add WindowWatcher/NativeMethods.cs
git commit -m "feat(watcher): add GetLastInputInfo P/Invoke for idle detection"

Task 2: Add IdleTimeoutMs to configuration

Files:

  • Modify: WindowWatcher/WindowWatcherOptions.cs
  • Modify: WindowWatcher/appsettings.json

Step 1: Add IdleTimeoutMs property

In WindowWatcherOptions.cs, add:

public int IdleTimeoutMs { get; set; } = 300_000;

Step 2: Add to appsettings.json

Add "IdleTimeoutMs": 300000 to the "WindowWatcher" section:

"WindowWatcher": {
    "ApiBaseUrl": "http://localhost:5200",
    "PollIntervalMs": 2000,
    "DebounceMs": 3000,
    "IdleTimeoutMs": 300000
}

Step 3: Commit

git add WindowWatcher/WindowWatcherOptions.cs WindowWatcher/appsettings.json
git commit -m "feat(watcher): add configurable IdleTimeoutMs setting (default 5 min)"

Task 3: Add idle detection logic to Worker

Files:

  • Modify: WindowWatcher/Worker.cs

This is the main task. Add idle state tracking and API calls for pause/resume.

Step 1: Add idle tracking fields

Add these fields to the Worker class (after the existing _lastChangeTime field):

private bool _isIdle;
private int? _pausedTaskId;

Step 2: Add idle check to the polling loop

At the end of the try block in ExecuteAsync (after the window-change detection block, before the catch), add:

// Idle detection
var idleTime = NativeMethods.GetIdleTime();
if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
{
    _isIdle = true;
    logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
    await PauseActiveTaskAsync(ct: stoppingToken);
}
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
{
    _isIdle = false;
    logger.LogInformation("User returned from idle");
    await ResumeIdlePausedTaskAsync(ct: stoppingToken);
}

Step 3: Update the startup log to include idle timeout

Change the existing LogInformation line to:

logger.LogInformation(
    "WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
    config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);

Step 4: Add PauseActiveTaskAsync method

private async Task PauseActiveTaskAsync(CancellationToken ct)
{
    try
    {
        var client = httpClientFactory.CreateClient("TaskTrackerApi");

        // Get the active task
        var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
            "/api/tasks/active", ct);

        if (response?.Data is null)
        {
            logger.LogDebug("No active task to pause");
            return;
        }

        _pausedTaskId = response.Data.Id;

        // Pause it
        var pauseResponse = await client.PutAsJsonAsync(
            $"/api/tasks/{_pausedTaskId}/pause",
            new { note = "Auto-paused: idle timeout" }, ct);

        if (pauseResponse.IsSuccessStatusCode)
            logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
        else
            logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
    }
    catch (Exception ex)
    {
        logger.LogWarning(ex, "Failed to pause active task on idle");
    }
}

Step 5: Add ResumeIdlePausedTaskAsync method

private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
{
    if (_pausedTaskId is null)
        return;

    var taskId = _pausedTaskId.Value;
    _pausedTaskId = null;

    try
    {
        var client = httpClientFactory.CreateClient("TaskTrackerApi");

        // Check the task is still paused (user may have manually switched tasks)
        var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
            $"/api/tasks/{taskId}", ct);

        if (response?.Data is null || response.Data.Status != "Paused")
        {
            logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
            return;
        }

        // Resume it
        var resumeResponse = await client.PutAsJsonAsync(
            $"/api/tasks/{taskId}/resume",
            new { note = "Auto-resumed: user returned" }, ct);

        if (resumeResponse.IsSuccessStatusCode)
            logger.LogInformation("Auto-resumed task {TaskId}", taskId);
        else
            logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode);
    }
    catch (Exception ex)
    {
        logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId);
    }
}

Step 6: Add the minimal DTO for deserializing API responses

Add a simple record at the bottom of Worker.cs (file-scoped, internal):

internal record ActiveTaskDto(int Id, string Status);

This is all we need to deserialize from the ApiResponse<T> wrapper — System.Text.Json will ignore extra properties.

Step 7: Add the missing using for JSON deserialization

Verify using System.Net.Http.Json; already exists (it does — line 2 of Worker.cs). No changes needed.

Step 8: Commit

git add WindowWatcher/Worker.cs
git commit -m "feat(watcher): add idle detection with auto-pause/resume"

Task 4: Build and verify

Step 1: Build the project

cd WindowWatcher && dotnet build

Expected: Build succeeded with 0 errors.

Step 2: Fix any build errors

If there are errors, fix them.

Step 3: Commit any fixes

If fixes were needed:

git add -A && git commit -m "fix(watcher): fix build errors in idle detection"