From c11ddb33c08b5fb18a19b1081f4863cbe28b1116 Mon Sep 17 00:00:00 2001 From: ognjeeen Date: Fri, 7 Aug 2026 11:40:12 +0200 Subject: [PATCH 1/8] feat: add Codex task activity dots --- README.md | 33 ++ docs/ARCHITECTURE.md | 18 +- src/CodexUsageWidget/App.xaml.cs | 7 + .../Application/CodexActivityMonitor.cs | 80 ++++ .../Application/CodexActivitySignal.cs | 13 + .../Application/ICodexActivitySignalSource.cs | 8 + src/CodexUsageWidget/CodexUsageWidget.csproj | 1 + .../Codex/Hooks/CodexActivityCommandLine.cs | 100 +++++ .../Codex/Hooks/CodexActivityHookCommand.cs | 45 +++ .../Hooks/CodexActivityHookPayloadParser.cs | 79 ++++ .../Codex/Hooks/CodexActivityPipeClient.cs | 49 +++ .../Hooks/CodexActivityPipeSignalSource.cs | 167 ++++++++ .../Hooks/CodexHookConfigurationManager.cs | 381 ++++++++++++++++++ src/CodexUsageWidget/Program.cs | 19 + src/CodexUsageWidget/Views/MainWindow.xaml.cs | 8 + .../Views/TaskbarLabelWindow.xaml | 169 +++++++- .../Views/TaskbarLabelWindow.xaml.cs | 84 ++++ .../CodexActivityHookPayloadParserTests.cs | 66 +++ .../CodexActivityMonitorTests.cs | 94 +++++ .../CodexActivityPipeTests.cs | 68 ++++ .../CodexHookConfigurationManagerTests.cs | 232 +++++++++++ 21 files changed, 1708 insertions(+), 13 deletions(-) create mode 100644 src/CodexUsageWidget/Application/CodexActivityMonitor.cs create mode 100644 src/CodexUsageWidget/Application/CodexActivitySignal.cs create mode 100644 src/CodexUsageWidget/Application/ICodexActivitySignalSource.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityCommandLine.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookCommand.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookPayloadParser.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeClient.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeSignalSource.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs create mode 100644 src/CodexUsageWidget/Program.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexActivityHookPayloadParserTests.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexActivityMonitorTests.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexActivityPipeTests.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexHookConfigurationManagerTests.cs diff --git a/README.md b/README.md index 43e8a44..0331d1d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ notifications. - Credit, spend-control, earned-reset, and model-specific limit details when available - Compact, movable, always-on-top desktop widget - Native-looking taskbar label beside the Windows notification area +- Event-driven task activity animation through official local Codex lifecycle hooks - Immediate taskbar-label hiding while another app is fullscreen on the same monitor - Persistent desktop/taskbar display preference - Automatic refresh every two minutes and live server notifications @@ -72,6 +73,38 @@ consumption because tokens do not map linearly to the remaining subscription per Use the `−` button to switch to taskbar mode. Right-click the taskbar label or tray icon to refresh, change display mode, or exit. +## Codex activity hooks + +The taskbar dots can react to real Codex work without polling. Hook installation is +an explicit, reviewable action and is never performed during normal widget startup. +From PowerShell in the directory containing the widget executable, run: + +```powershell +.\CodexUsageWidget.exe --install-activity-hooks +``` + +The command displays the proposed `~/.codex/hooks.json` content and writes it only +after interactive confirmation. It preserves existing hooks and unknown fields. +After installation, start Codex, open `/hooks`, and review and trust the exact new +`UserPromptSubmit`, `Stop`, and `SessionEnd` definitions. New or changed definitions +require new trust. + +To remove only handlers that exactly match the current widget executable, run: + +```powershell +.\CodexUsageWidget.exe --uninstall-activity-hooks +``` + +The hook handler sends only an activity kind, session ID, and turn ID over a local +current-user named pipe. Prompts, assistant messages, transcript paths, and model +output are neither forwarded nor logged. If the widget is closed, the handler exits +successfully after a short bounded connection attempt and Codex continues normally. + +Activity state is intentionally in memory only. A task that started before the widget +or hooks were ready is not reconstructed. If Codex terminates without emitting `Stop` +or `SessionEnd`, the indicator can remain active until the widget restarts; no arbitrary +timeout is used because legitimate tasks can run for a long time. + ## Development The repository pins the .NET SDK in `global.json`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50b66bd..7de2f04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,10 +8,10 @@ dependency-injection package. ```text src/CodexUsageWidget/ -├── Application/ Refresh orchestration and shared presentation formatting +├── Application/ Refresh orchestration, activity state and presentation formatting ├── Domain/ Rate-limit, credit, spend-control and activity models ├── Infrastructure/ -│ ├── Codex/ CLI discovery, app-server session, JSON-RPC and parsers +│ ├── Codex/ App-server integration plus lifecycle-hook parsing and local IPC │ ├── Logging/ Local file diagnostics │ ├── Settings/ Persistent display preference │ └── Windows/ Tray icon and taskbar Win32 integration @@ -21,14 +21,18 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence ## Runtime flow -1. `App` acquires the single-instance mutex and constructs the object graph. +1. `App` handles activity-hook/configuration command modes before acquiring the + single-instance mutex, then constructs the normal widget object graph. 2. `UsageMonitor` owns refresh scheduling, timeout handling and refresh coalescing. 3. `CodexUsageProvider` coordinates required rate-limit reads and optional token-activity reads. 4. `CodexAppServerSession` owns initialized app-server connection lifetime. 5. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime. 6. Endpoint-specific parsers convert Codex payloads into domain records. -7. `UsageWidgetViewModel` maps snapshots to immutable presentation state. -8. `MainWindow` remains a window-lifecycle shell while focused user controls render +7. `CodexActivityPipeSignalSource` receives minimal lifecycle signals over a + current-user-only named pipe; `CodexActivityMonitor` owns the active turn set and + emits only final boolean transitions. +8. `UsageWidgetViewModel` maps snapshots to immutable presentation state. +9. `MainWindow` remains a window-lifecycle shell while focused user controls render compact, detailed, and repeated limit-row content. ## Dependency direction @@ -46,6 +50,10 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence - Optional token-activity failures degrade only the detailed activity section; core rate-limit monitoring remains available. - A semaphore prevents concurrent refreshes and a mutex prevents duplicate apps. +- Activity hook IPC is bounded and local to the current Windows user. Duplicate turn + lifecycle events are idempotent and session end removes only that session's turns. +- Activity state is not persisted or reconstructed with polling. Missing cleanup after + a hard Codex crash is cleared by restarting the widget. - Unhandled exceptions and CLI diagnostics are recorded locally for support. - Publish trimming is disabled because WPF is not a safe trimming boundary. diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index e92cbad..6093700 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -2,6 +2,7 @@ using CodexUsageWidget.Application; using CodexUsageWidget.Infrastructure; using CodexUsageWidget.Infrastructure.Codex; +using CodexUsageWidget.Infrastructure.Codex.Hooks; using CodexUsageWidget.Infrastructure.Logging; using CodexUsageWidget.Infrastructure.Settings; using CodexUsageWidget.Infrastructure.Windows; @@ -31,18 +32,23 @@ protected override void OnStartup(StartupEventArgs e) _logger = new FileLogger(AppPaths.LogDirectory); _exceptionHandler = new GlobalExceptionHandler(this, _logger); + CodexActivityMonitor? activityMonitor = null; try { var usageProvider = new CodexUsageProvider(new CodexAppServerSession()); var usageMonitor = new UsageMonitor(usageProvider); usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); + activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource()); + var window = new MainWindow( usageMonitor, + activityMonitor, new DisplayModeStore(), new WidgetDensityStore(), new TrayIconService()); MainWindow = window; + activityMonitor.StartAsync().GetAwaiter().GetResult(); window.Show(); if (window.StartsInTaskbarIndicatorMode) { @@ -53,6 +59,7 @@ protected override void OnStartup(StartupEventArgs e) } catch (Exception ex) { + activityMonitor?.DisposeAsync().AsTask().GetAwaiter().GetResult(); _logger.LogError("Application startup failed.", ex); System.Windows.MessageBox.Show( "Codex Usage Widget could not start. See the log under " + AppPaths.LogDirectory, diff --git a/src/CodexUsageWidget/Application/CodexActivityMonitor.cs b/src/CodexUsageWidget/Application/CodexActivityMonitor.cs new file mode 100644 index 0000000..b0e3879 --- /dev/null +++ b/src/CodexUsageWidget/Application/CodexActivityMonitor.cs @@ -0,0 +1,80 @@ +namespace CodexUsageWidget.Application; + +public sealed class CodexActivityMonitor : IAsyncDisposable +{ + private readonly object _stateLock = new(); + private readonly ICodexActivitySignalSource _source; + private readonly HashSet _activeTurns = []; + private bool _started; + + public CodexActivityMonitor(ICodexActivitySignalSource source) + { + _source = source; + } + + public event Action? ActivityChanged; + + public bool IsActive + { + get + { + lock (_stateLock) + { + return _activeTurns.Count > 0; + } + } + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (_started) + { + return; + } + + _started = true; + _source.SignalReceived += SourceOnSignalReceived; + await _source.StartAsync(cancellationToken).ConfigureAwait(false); + } + + private void SourceOnSignalReceived(CodexActivitySignal signal) + { + bool? changedState = null; + lock (_stateLock) + { + var wasActive = _activeTurns.Count > 0; + switch (signal.Kind) + { + case CodexActivitySignalKind.TurnStarted when signal.TurnId is not null: + _activeTurns.Add(new ActiveTurn(signal.SessionId, signal.TurnId)); + break; + case CodexActivitySignalKind.TurnStopped when signal.TurnId is not null: + _activeTurns.Remove(new ActiveTurn(signal.SessionId, signal.TurnId)); + break; + case CodexActivitySignalKind.SessionEnded: + _activeTurns.RemoveWhere(turn => + string.Equals(turn.SessionId, signal.SessionId, StringComparison.Ordinal)); + break; + } + + var currentActivity = _activeTurns.Count > 0; + if (wasActive != currentActivity) + { + changedState = currentActivity; + } + } + + if (changedState is { } emittedActivity) + { + ActivityChanged?.Invoke(emittedActivity); + } + } + + public async ValueTask DisposeAsync() + { + _source.SignalReceived -= SourceOnSignalReceived; + await _source.DisposeAsync().ConfigureAwait(false); + } + + private readonly record struct ActiveTurn(string SessionId, string TurnId); +} diff --git a/src/CodexUsageWidget/Application/CodexActivitySignal.cs b/src/CodexUsageWidget/Application/CodexActivitySignal.cs new file mode 100644 index 0000000..d81b559 --- /dev/null +++ b/src/CodexUsageWidget/Application/CodexActivitySignal.cs @@ -0,0 +1,13 @@ +namespace CodexUsageWidget.Application; + +public enum CodexActivitySignalKind +{ + TurnStarted, + TurnStopped, + SessionEnded +} + +public sealed record CodexActivitySignal( + CodexActivitySignalKind Kind, + string SessionId, + string? TurnId = null); diff --git a/src/CodexUsageWidget/Application/ICodexActivitySignalSource.cs b/src/CodexUsageWidget/Application/ICodexActivitySignalSource.cs new file mode 100644 index 0000000..aa9be8a --- /dev/null +++ b/src/CodexUsageWidget/Application/ICodexActivitySignalSource.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public interface ICodexActivitySignalSource : IAsyncDisposable +{ + event Action? SignalReceived; + + Task StartAsync(CancellationToken cancellationToken = default); +} diff --git a/src/CodexUsageWidget/CodexUsageWidget.csproj b/src/CodexUsageWidget/CodexUsageWidget.csproj index 84b0ae5..ce5311a 100644 --- a/src/CodexUsageWidget/CodexUsageWidget.csproj +++ b/src/CodexUsageWidget/CodexUsageWidget.csproj @@ -1,6 +1,7 @@ WinExe + CodexUsageWidget.Program net10.0-windows10.0.17763.0 true true diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityCommandLine.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityCommandLine.cs new file mode 100644 index 0000000..a47b85f --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityCommandLine.cs @@ -0,0 +1,100 @@ +using System.IO; +using System.Text; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public static class CodexActivityCommandLine +{ + private const string HookArgument = "--codex-activity-hook"; + private const string InstallArgument = "--install-activity-hooks"; + private const string UninstallArgument = "--uninstall-activity-hooks"; + + public static bool IsCommandMode(IReadOnlyList arguments) => + arguments.Count == 1 && + arguments[0] is HookArgument or InstallArgument or UninstallArgument; + + public static async Task RunAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken = default) + { + await using var inputStream = Console.OpenStandardInput(); + await using var outputStream = Console.OpenStandardOutput(); + + if (arguments[0] == HookArgument) + { + return await CodexActivityHookCommand.RunAsync( + inputStream, + outputStream, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + using var input = new StreamReader( + inputStream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: true); + await using var output = new StreamWriter( + outputStream, + new UTF8Encoding(false), + leaveOpen: true) + { + AutoFlush = true + }; + + try + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(processPath)) + { + await output.WriteLineAsync("Cannot determine the widget executable path.") + .ConfigureAwait(false); + return 1; + } + + var manager = new CodexHookConfigurationManager(); + var install = arguments[0] == InstallArgument; + var plan = install + ? manager.PlanInstall(processPath) + : manager.PlanUninstall(processPath); + if (plan.Error is not null) + { + await output.WriteLineAsync(plan.Error).ConfigureAwait(false); + return 1; + } + + if (!plan.HasChanges) + { + await output.WriteLineAsync( + install + ? "Activity hooks are already installed." + : "No matching activity hooks are installed.").ConfigureAwait(false); + return 0; + } + + await output.WriteLineAsync("Proposed ~/.codex/hooks.json:").ConfigureAwait(false); + await output.WriteLineAsync(plan.ProposedContent).ConfigureAwait(false); + await output.WriteAsync("Apply this change? [y/N] ").ConfigureAwait(false); + var approval = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (!string.Equals(approval, "y", StringComparison.OrdinalIgnoreCase) && + !string.Equals(approval, "yes", StringComparison.OrdinalIgnoreCase)) + { + await output.WriteLineAsync("No changes were made.").ConfigureAwait(false); + return 0; + } + + manager.Apply(plan); + await output.WriteLineAsync( + install + ? "Activity hooks installed. Review and trust the exact definitions with /hooks." + : "Matching activity hooks removed.").ConfigureAwait(false); + return 0; + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + await output.WriteLineAsync($"Activity hook configuration failed: {ex.Message}") + .ConfigureAwait(false); + return 1; + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookCommand.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookCommand.cs new file mode 100644 index 0000000..1b891a0 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookCommand.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Text; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public static class CodexActivityHookCommand +{ + private static readonly byte[] NeutralResult = Encoding.UTF8.GetBytes("{\"continue\":true}\n"); + + public static async Task RunAsync( + Stream input, + Stream output, + string pipeName = CodexActivityPipeClient.DefaultPipeName, + CancellationToken cancellationToken = default) + { + try + { + var signal = await CodexActivityHookPayloadParser.ParseAsync(input, cancellationToken) + .ConfigureAwait(false); + if (signal is not null) + { + await CodexActivityPipeClient.TrySendAsync( + signal, + pipeName, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + catch + { + // Activity reporting must never interfere with Codex. + } + + try + { + await output.WriteAsync(NeutralResult, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // A closed stdout must not turn this advisory hook into a failure. + } + + return 0; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookPayloadParser.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookPayloadParser.cs new file mode 100644 index 0000000..6ea13e6 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookPayloadParser.cs @@ -0,0 +1,79 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public static class CodexActivityHookPayloadParser +{ + public static bool TryParse(string json, out CodexActivitySignal? signal) + { + try + { + var payload = JsonSerializer.Deserialize(json); + signal = ToSignal(payload); + return signal is not null; + } + catch (JsonException) + { + signal = null; + return false; + } + } + + public static async Task ParseAsync( + Stream input, + CancellationToken cancellationToken = default) + { + try + { + var payload = await JsonSerializer.DeserializeAsync( + input, + cancellationToken: cancellationToken).ConfigureAwait(false); + return ToSignal(payload); + } + catch (JsonException) + { + return null; + } + } + + private static CodexActivitySignal? ToSignal(HookPayloadFields? payload) + { + if (payload is null || string.IsNullOrWhiteSpace(payload.SessionId)) + { + return null; + } + + return payload.HookEventName switch + { + "UserPromptSubmit" when !string.IsNullOrWhiteSpace(payload.TurnId) => + new CodexActivitySignal( + CodexActivitySignalKind.TurnStarted, + payload.SessionId, + payload.TurnId), + "Stop" when !string.IsNullOrWhiteSpace(payload.TurnId) => + new CodexActivitySignal( + CodexActivitySignalKind.TurnStopped, + payload.SessionId, + payload.TurnId), + "SessionEnd" => new CodexActivitySignal( + CodexActivitySignalKind.SessionEnded, + payload.SessionId), + _ => null + }; + } + + private sealed class HookPayloadFields + { + [JsonPropertyName("hook_event_name")] + public string? HookEventName { get; init; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; init; } + + [JsonPropertyName("turn_id")] + public string? TurnId { get; init; } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeClient.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeClient.cs new file mode 100644 index 0000000..647a202 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeClient.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; +using System.IO; +using System.IO.Pipes; +using System.Text.Json; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public static class CodexActivityPipeClient +{ + private const int MaximumPayloadBytes = 4096; + public const string DefaultPipeName = "CodexUsageWidget.Activity.v1"; + public const int DefaultConnectTimeoutMilliseconds = 150; + + public static async Task TrySendAsync( + CodexActivitySignal signal, + string pipeName = DefaultPipeName, + int connectTimeoutMilliseconds = DefaultConnectTimeoutMilliseconds, + CancellationToken cancellationToken = default) + { + try + { + var payload = JsonSerializer.SerializeToUtf8Bytes(signal); + if (payload.Length > MaximumPayloadBytes) + { + return false; + } + + var lengthPrefix = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(lengthPrefix, payload.Length); + using var pipe = new NamedPipeClientStream( + ".", + pipeName, + PipeDirection.Out, + PipeOptions.Asynchronous); + await pipe.ConnectAsync(connectTimeoutMilliseconds, cancellationToken) + .ConfigureAwait(false); + await pipe.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + await pipe.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await pipe.FlushAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) when ( + ex is IOException or TimeoutException or OperationCanceledException or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeSignalSource.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeSignalSource.cs new file mode 100644 index 0000000..2a75270 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeSignalSource.cs @@ -0,0 +1,167 @@ +using System.Buffers.Binary; +using System.IO; +using System.IO.Pipes; +using System.Text.Json; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public sealed class CodexActivityPipeSignalSource : ICodexActivitySignalSource +{ + private const int MaximumPayloadBytes = 4096; + private const int MaximumIdentifierLength = 256; + + private readonly string _pipeName; + private readonly CancellationTokenSource _lifetime = new(); + private readonly object _clientTasksLock = new(); + private readonly HashSet _clientTasks = []; + private Task? _listenTask; + + public CodexActivityPipeSignalSource(string pipeName = CodexActivityPipeClient.DefaultPipeName) + { + _pipeName = pipeName; + } + + public event Action? SignalReceived; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _listenTask ??= ListenAsync(_lifetime.Token); + return Task.CompletedTask; + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var pipe = CreateServer(); + try + { + await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + pipe.Dispose(); + throw; + } + + TrackClientTask(HandleClientAsync(pipe, cancellationToken)); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private NamedPipeServerStream CreateServer() => new( + _pipeName, + PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, + MaximumPayloadBytes, + MaximumPayloadBytes); + + private async Task HandleClientAsync( + NamedPipeServerStream pipe, + CancellationToken cancellationToken) + { + using (pipe) + { + try + { + var signal = await ReadSignalAsync(pipe, cancellationToken).ConfigureAwait(false); + if (signal is not null) + { + SignalReceived?.Invoke(signal); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + } + + private void TrackClientTask(Task task) + { + lock (_clientTasksLock) + { + _clientTasks.Add(task); + } + + _ = task.ContinueWith( + completedTask => + { + lock (_clientTasksLock) + { + _clientTasks.Remove(completedTask); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static async Task ReadSignalAsync( + Stream input, + CancellationToken cancellationToken) + { + var lengthPrefix = new byte[sizeof(int)]; + try + { + await input.ReadExactlyAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is EndOfStreamException or IOException) + { + return null; + } + + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(lengthPrefix); + if (payloadLength <= 0 || payloadLength > MaximumPayloadBytes) + { + return null; + } + + var payload = new byte[payloadLength]; + try + { + await input.ReadExactlyAsync(payload, cancellationToken).ConfigureAwait(false); + var signal = JsonSerializer.Deserialize(payload); + return IsValid(signal) ? signal : null; + } + catch (Exception ex) when (ex is EndOfStreamException or IOException or JsonException) + { + return null; + } + } + + private static bool IsValid(CodexActivitySignal? signal) => + signal is not null && + !string.IsNullOrWhiteSpace(signal.SessionId) && + signal.SessionId.Length <= MaximumIdentifierLength && + (signal.Kind == CodexActivitySignalKind.SessionEnded || + (!string.IsNullOrWhiteSpace(signal.TurnId) && + signal.TurnId.Length <= MaximumIdentifierLength)); + + public async ValueTask DisposeAsync() + { + await _lifetime.CancelAsync().ConfigureAwait(false); + if (_listenTask is not null) + { + await _listenTask.ConfigureAwait(false); + } + + Task[] clientTasks; + lock (_clientTasksLock) + { + clientTasks = [.. _clientTasks]; + } + + await Task.WhenAll(clientTasks).ConfigureAwait(false); + + _lifetime.Dispose(); + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs new file mode 100644 index 0000000..99f548e --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs @@ -0,0 +1,381 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public sealed partial class CodexHookConfigurationManager +{ + private const int HookTimeoutSeconds = 3; + private const int LegacyHookTimeoutSeconds = 1; + private static readonly string[] ActivityEvents = ["UserPromptSubmit", "Stop", "SessionEnd"]; + private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; + + private readonly string _hooksPath; + private readonly string _configPath; + + public CodexHookConfigurationManager(string? hooksPath = null, string? configPath = null) + { + var codexHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex"); + _hooksPath = hooksPath ?? Path.Combine(codexHome, "hooks.json"); + _configPath = configPath ?? Path.Combine(codexHome, "config.toml"); + } + + public CodexHookConfigurationPlan PlanInstall(string processPath) + { + if (string.IsNullOrWhiteSpace(processPath) || !Path.IsPathFullyQualified(processPath)) + { + return ErrorPlan("The widget executable path must be absolute."); + } + + var featureError = GetDisabledFeatureError(); + if (featureError is not null) + { + return ErrorPlan(featureError); + } + + return PlanChange(processPath, install: true); + } + + public CodexHookConfigurationPlan PlanUninstall(string processPath) + { + if (string.IsNullOrWhiteSpace(processPath) || !Path.IsPathFullyQualified(processPath)) + { + return ErrorPlan("The widget executable path must be absolute."); + } + + return PlanChange(processPath, install: false); + } + + public void Apply(CodexHookConfigurationPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + if (plan.Error is not null) + { + throw new InvalidOperationException(plan.Error); + } + + if (!plan.HasChanges) + { + return; + } + + var exists = File.Exists(_hooksPath); + var currentContent = exists ? File.ReadAllText(_hooksPath) : null; + if (exists != plan.OriginalExisted || + !string.Equals(currentContent, plan.OriginalContent, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Codex hooks.json changed after the preview. Review the new file and try again."); + } + + var directory = Path.GetDirectoryName(_hooksPath)!; + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(_hooksPath)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporaryPath, plan.ProposedContent, new UTF8Encoding(false)); + File.Move(temporaryPath, _hooksPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + public static string BuildHookCommand(string processPath) + { + var escapedProcessPath = processPath.Replace("'", "''", StringComparison.Ordinal); + return $"& '{escapedProcessPath}' --codex-activity-hook"; + } + + private static string BuildLegacyHookCommand(string processPath) => + $"\"{processPath}\" --codex-activity-hook"; + + private static string BuildNestedPowerShellHookCommand(string processPath) + { + var escapedProcessPath = processPath.Replace("'", "''", StringComparison.Ordinal); + return "powershell.exe -NoLogo -NoProfile -NonInteractive " + + "-ExecutionPolicy Bypass -Command " + + $"\"& '{escapedProcessPath}' --codex-activity-hook\""; + } + + private CodexHookConfigurationPlan PlanChange(string processPath, bool install) + { + var originalExisted = File.Exists(_hooksPath); + string? originalContent = null; + JsonObject root; + try + { + if (originalExisted) + { + originalContent = File.ReadAllText(_hooksPath); + root = JsonNode.Parse(originalContent) as JsonObject ?? + throw new JsonException("The root value is not an object."); + } + else + { + root = new JsonObject(); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return ErrorPlan( + $"Cannot safely modify '{_hooksPath}': {ex.Message}", + originalExisted, + originalContent); + } + + if (root["hooks"] is not null && root["hooks"] is not JsonObject) + { + return ErrorPlan( + $"Cannot safely modify '{_hooksPath}': 'hooks' is not a JSON object.", + originalExisted, + originalContent); + } + + var hooks = root["hooks"] as JsonObject; + if (install) + { + hooks ??= new JsonObject(); + root["hooks"] = hooks; + } + else if (hooks is null) + { + return NoChangePlan(originalExisted, originalContent); + } + + var changed = false; + var command = BuildHookCommand(processPath); + var legacyCommand = BuildLegacyHookCommand(processPath); + var nestedPowerShellCommand = BuildNestedPowerShellHookCommand(processPath); + foreach (var eventName in ActivityEvents) + { + if (hooks[eventName] is not null && hooks[eventName] is not JsonArray) + { + return ErrorPlan( + $"Cannot safely modify '{_hooksPath}': hooks.{eventName} is not a JSON array.", + originalExisted, + originalContent); + } + + var groups = hooks[eventName] as JsonArray; + if (install) + { + groups ??= new JsonArray(); + hooks[eventName] = groups; + changed |= ReplaceHandlers( + groups, + legacyCommand, + LegacyHookTimeoutSeconds, + command); + changed |= ReplaceHandlers( + groups, + nestedPowerShellCommand, + HookTimeoutSeconds, + command); + if (!ContainsHandler(groups, command)) + { + groups.Add(new JsonObject + { + ["hooks"] = new JsonArray(CreateHandler(command)) + }); + changed = true; + } + } + else if (groups is not null) + { + changed |= RemoveHandlers(groups, command, HookTimeoutSeconds); + changed |= RemoveHandlers( + groups, + legacyCommand, + LegacyHookTimeoutSeconds); + changed |= RemoveHandlers( + groups, + nestedPowerShellCommand, + HookTimeoutSeconds); + } + } + + if (!changed) + { + return NoChangePlan(originalExisted, originalContent); + } + + var proposedContent = root.ToJsonString(IndentedJson) + Environment.NewLine; + return new CodexHookConfigurationPlan( + hasChanges: true, + proposedContent, + error: null, + originalExisted, + originalContent); + } + + private string? GetDisabledFeatureError() + { + try + { + if (!File.Exists(_configPath)) + { + return null; + } + + var inFeaturesSection = false; + foreach (var line in File.ReadLines(_configPath)) + { + if (TomlSectionRegex().IsMatch(line)) + { + inFeaturesSection = FeaturesSectionRegex().IsMatch(line); + continue; + } + + if (inFeaturesSection && DisabledHooksRegex().IsMatch(line)) + { + return "Codex hooks are explicitly disabled in [features]. " + + "Set 'hooks = true' yourself before installing activity hooks."; + } + } + + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return $"Cannot verify whether Codex hooks are enabled in '{_configPath}': {ex.Message}"; + } + } + + private static bool ContainsHandler(JsonArray groups, string command) + { + var expected = CreateHandler(command); + return groups + .OfType() + .Select(group => group["hooks"]) + .OfType() + .SelectMany(handlers => handlers) + .Any(handler => JsonNode.DeepEquals(handler, expected)); + } + + private static bool RemoveHandlers(JsonArray groups, string command, int timeoutSeconds) + { + var expected = CreateHandler(command, timeoutSeconds); + var changed = false; + foreach (var handlers in groups + .OfType() + .Select(group => group["hooks"]) + .OfType()) + { + for (var index = handlers.Count - 1; index >= 0; index--) + { + if (JsonNode.DeepEquals(handlers[index], expected)) + { + handlers.RemoveAt(index); + changed = true; + } + } + } + + return changed; + } + + private static bool ReplaceHandlers( + JsonArray groups, + string oldCommand, + int oldTimeoutSeconds, + string newCommand) + { + var expected = CreateHandler(oldCommand, oldTimeoutSeconds); + var replacement = CreateHandler(newCommand); + var changed = false; + foreach (var handlers in groups + .OfType() + .Select(group => group["hooks"]) + .OfType()) + { + for (var index = 0; index < handlers.Count; index++) + { + if (JsonNode.DeepEquals(handlers[index], expected)) + { + handlers[index] = replacement.DeepClone(); + changed = true; + } + } + } + + return changed; + } + + private static JsonObject CreateHandler( + string command, + int timeoutSeconds = HookTimeoutSeconds) => new() + { + ["type"] = "command", + ["command"] = command, + ["timeout"] = timeoutSeconds + }; + + private static CodexHookConfigurationPlan NoChangePlan( + bool originalExisted, + string? originalContent) => + new( + hasChanges: false, + proposedContent: originalContent ?? string.Empty, + error: null, + originalExisted, + originalContent); + + private static CodexHookConfigurationPlan ErrorPlan( + string error, + bool originalExisted = false, + string? originalContent = null) => + new( + hasChanges: false, + proposedContent: originalContent ?? string.Empty, + error, + originalExisted, + originalContent); + + [GeneratedRegex(@"^\s*\[[^\]]+\]\s*(?:#.*)?$")] + private static partial Regex TomlSectionRegex(); + + [GeneratedRegex(@"^\s*\[\s*features\s*\]\s*(?:#.*)?$")] + private static partial Regex FeaturesSectionRegex(); + + [GeneratedRegex("^\\s*(?:hooks|\"hooks\")\\s*=\\s*false\\s*(?:#.*)?$")] + private static partial Regex DisabledHooksRegex(); +} + +public sealed class CodexHookConfigurationPlan +{ + internal CodexHookConfigurationPlan( + bool hasChanges, + string proposedContent, + string? error, + bool originalExisted, + string? originalContent) + { + HasChanges = hasChanges; + ProposedContent = proposedContent; + Error = error; + OriginalExisted = originalExisted; + OriginalContent = originalContent; + } + + public bool HasChanges { get; } + + public string ProposedContent { get; } + + public string? Error { get; } + + internal bool OriginalExisted { get; } + + internal string? OriginalContent { get; } +} diff --git a/src/CodexUsageWidget/Program.cs b/src/CodexUsageWidget/Program.cs new file mode 100644 index 0000000..e777b5d --- /dev/null +++ b/src/CodexUsageWidget/Program.cs @@ -0,0 +1,19 @@ +using CodexUsageWidget.Infrastructure.Codex.Hooks; + +namespace CodexUsageWidget; + +internal static class Program +{ + [STAThread] + public static int Main(string[] args) + { + if (CodexActivityCommandLine.IsCommandMode(args)) + { + return CodexActivityCommandLine.RunAsync(args).GetAwaiter().GetResult(); + } + + var application = new App(); + application.InitializeComponent(); + return application.Run(); + } +} diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs index 49d2cef..a9c358a 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs @@ -19,6 +19,7 @@ public partial class MainWindow : Window private const double DetailedHeight = 620d; private readonly UsageMonitor _usageMonitor; + private readonly CodexActivityMonitor _activityMonitor; private readonly DisplayModeStore _displayModeStore; private readonly WidgetDensityStore _densityStore; private readonly TrayIconService _trayIcon; @@ -31,11 +32,13 @@ public partial class MainWindow : Window public MainWindow( UsageMonitor usageMonitor, + CodexActivityMonitor activityMonitor, DisplayModeStore displayModeStore, WidgetDensityStore densityStore, TrayIconService trayIcon) { _usageMonitor = usageMonitor; + _activityMonitor = activityMonitor; _displayModeStore = displayModeStore; _densityStore = densityStore; _trayIcon = trayIcon; @@ -60,6 +63,7 @@ private void WireEvents() _usageMonitor.RefreshStarted += UsageMonitorOnRefreshStarted; _usageMonitor.SnapshotUpdated += UsageMonitorOnSnapshotUpdated; _usageMonitor.RefreshFailed += UsageMonitorOnRefreshFailed; + _activityMonitor.ActivityChanged += ActivityMonitorOnActivityChanged; _taskbarLabel.OpenRequested += (_, _) => Dispatcher.BeginInvoke(_widgetVisibility.Show, DispatcherPriority.ApplicationIdle); @@ -101,6 +105,9 @@ private void UsageMonitorOnSnapshotUpdated(UsageSnapshot snapshot) => private void UsageMonitorOnRefreshFailed(string message) => Dispatcher.BeginInvoke(() => RenderError(message)); + private void ActivityMonitorOnActivityChanged(bool isActive) => + Dispatcher.BeginInvoke(() => _taskbarLabel.SetActivityState(isActive)); + private void RenderSnapshot(UsageSnapshot snapshot) { var nextViewModel = UsageWidgetViewModel.FromSnapshot(snapshot); @@ -278,6 +285,7 @@ private void MainWindowOnClosing(object? sender, CancelEventArgs e) _taskbarLabel.HideLabel(); _taskbarLabel.Close(); _trayIcon.Dispose(); + _activityMonitor.DisposeAsync().AsTask().GetAwaiter().GetResult(); _usageMonitor.DisposeAsync().AsTask().GetAwaiter().GetResult(); System.Windows.Application.Current.Shutdown(); } diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml index e38ff9f..06af1c2 100644 --- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml +++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml @@ -13,6 +13,122 @@ Focusable="False" SnapsToDevicePixels="True" UseLayoutRounding="True"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -45,14 +165,47 @@ FontSize="12" TextOptions.TextFormattingMode="Ideal"> - - - - - + + + + + + + + + + + + + + + + + StartActivityWaveIfVisible(); + _activityCollapseStoryboard.Completed += (_, _) => ResetActivityAnimation(); + SourceInitialized += (_, _) => { _windowHandle = new WindowInteropHelper(this).Handle; @@ -62,9 +75,38 @@ public void HideLabel() { _labelRequested = false; _positionTimer.Stop(); + StopActivityWave(); Hide(); } + public void SetActivityState(bool isActive) + { + _realActivityIsActive = isActive; + ApplyEffectiveActivityState(); + } + + private void ApplyEffectiveActivityState() + { + var isActive = _realActivityIsActive || _previewActivityIsActive; + if (_isTaskActive == isActive) + { + return; + } + + _isTaskActive = isActive; + + if (isActive) + { + SetActivityLayout(isExpanded: true); + _activityCollapseStoryboard.Remove(this); + _activityExpandStoryboard.Begin(this, HandoffBehavior.SnapshotAndReplace, isControllable: true); + return; + } + + StopActivityWave(); + _activityCollapseStoryboard.Begin(this, HandoffBehavior.SnapshotAndReplace, isControllable: true); + } + public void UpdateUsage(double? remainingPercent, DateTimeOffset? resetsAt) { if (remainingPercent is null) @@ -100,6 +142,7 @@ private void UpdateVisibilityAndPosition() { if (IsVisible) { + StopActivityWave(); Hide(); } @@ -110,6 +153,7 @@ private void UpdateVisibilityAndPosition() { Reposition(); Show(); + StartActivityWaveIfVisible(); return; } @@ -132,6 +176,40 @@ private void QueueVisibilityUpdate() System.Windows.Threading.DispatcherPriority.Send); } + private Storyboard FindStoryboard(string resourceName) => + ((Storyboard)FindResource(resourceName)).Clone(); + + private void StartActivityWaveIfVisible() + { + if (_isTaskActive && IsVisible) + { + _activityWaveStoryboard.Begin(this, HandoffBehavior.SnapshotAndReplace, isControllable: true); + } + } + + private void StopActivityWave() => _activityWaveStoryboard.Remove(this); + + private void ResetActivityAnimation() + { + if (_isTaskActive) + { + return; + } + + _activityExpandStoryboard.Remove(this); + _activityCollapseStoryboard.Remove(this); + SetActivityLayout(isExpanded: false); + } + + private void SetActivityLayout(bool isExpanded) + { + Width = isExpanded ? 102d : 94d; + LabelSurface.Padding = isExpanded + ? new Thickness(8d, 0d, 0d, 0d) + : new Thickness(0d); + Reposition(); + } + private void LabelSurface_OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e) => ToggleRequested?.Invoke(this, EventArgs.Empty); @@ -141,6 +219,12 @@ private void OpenMenuItem_OnClick(object sender, RoutedEventArgs e) => private void RefreshMenuItem_OnClick(object sender, RoutedEventArgs e) => RefreshRequested?.Invoke(this, EventArgs.Empty); + private void ActivityPreviewMenuItem_OnClick(object sender, RoutedEventArgs e) + { + _previewActivityIsActive = ActivityPreviewMenuItem.IsChecked; + ApplyEffectiveActivityState(); + } + private void DesktopModeMenuItem_OnClick(object sender, RoutedEventArgs e) => DesktopModeRequested?.Invoke(this, EventArgs.Empty); diff --git a/tests/CodexUsageWidget.Tests/CodexActivityHookPayloadParserTests.cs b/tests/CodexUsageWidget.Tests/CodexActivityHookPayloadParserTests.cs new file mode 100644 index 0000000..2ff0382 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/CodexActivityHookPayloadParserTests.cs @@ -0,0 +1,66 @@ +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Codex.Hooks; + +namespace CodexUsageWidget.Tests; + +public sealed class CodexActivityHookPayloadParserTests +{ + [Theory] + [InlineData("UserPromptSubmit", CodexActivitySignalKind.TurnStarted)] + [InlineData("Stop", CodexActivitySignalKind.TurnStopped)] + public void ParsesTurnScopedEvents(string eventName, CodexActivitySignalKind expectedKind) + { + var json = $$""" + {"hook_event_name":"{{eventName}}","session_id":"session-1","turn_id":"turn-1"} + """; + + var parsed = CodexActivityHookPayloadParser.TryParse(json, out var signal); + + Assert.True(parsed); + Assert.Equal(expectedKind, signal!.Kind); + Assert.Equal("session-1", signal.SessionId); + Assert.Equal("turn-1", signal.TurnId); + } + + [Fact] + public void ParsesSessionEndWithoutTurnId() + { + const string Json = + "{\"hook_event_name\":\"SessionEnd\",\"session_id\":\"session-1\"}"; + + var parsed = CodexActivityHookPayloadParser.TryParse(Json, out var signal); + + Assert.True(parsed); + Assert.Equal(CodexActivitySignalKind.SessionEnded, signal!.Kind); + Assert.Null(signal.TurnId); + } + + [Theory] + [InlineData("{\"hook_event_name\":\"Stop\",\"turn_id\":\"turn-1\"}")] + [InlineData("{\"hook_event_name\":\"Stop\",\"session_id\":\"session-1\"}")] + [InlineData("{\"hook_event_name\":\"Unknown\",\"session_id\":\"session-1\",\"turn_id\":\"turn-1\"}")] + [InlineData("{not json")] + public void RejectsInvalidOrUnsupportedPayload(string json) + { + Assert.False(CodexActivityHookPayloadParser.TryParse(json, out var signal)); + Assert.Null(signal); + } + + [Fact] + public void SensitivePayloadFieldsDoNotEnterSignal() + { + const string Json = """ + { + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "turn_id": "turn-1", + "prompt": "private prompt", + "last_assistant_message": "private answer", + "transcript_path": "private path" + } + """; + + Assert.True(CodexActivityHookPayloadParser.TryParse(Json, out var signal)); + Assert.DoesNotContain("private", signal!.ToString(), StringComparison.Ordinal); + } +} diff --git a/tests/CodexUsageWidget.Tests/CodexActivityMonitorTests.cs b/tests/CodexUsageWidget.Tests/CodexActivityMonitorTests.cs new file mode 100644 index 0000000..eaf54af --- /dev/null +++ b/tests/CodexUsageWidget.Tests/CodexActivityMonitorTests.cs @@ -0,0 +1,94 @@ +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Tests; + +public sealed class CodexActivityMonitorTests +{ + [Fact] + public async Task FirstStartEnablesActivityAndDuplicateStartIsIdempotent() + { + await using var source = new FakeSignalSource(); + await using var monitor = new CodexActivityMonitor(source); + var changes = new List(); + monitor.ActivityChanged += changes.Add; + await monitor.StartAsync(); + + var start = new CodexActivitySignal(CodexActivitySignalKind.TurnStarted, "session", "turn"); + source.Publish(start); + source.Publish(start); + + Assert.True(monitor.IsActive); + Assert.Equal([true], changes); + } + + [Fact] + public async Task ParallelTurnsStayActiveUntilBothStop() + { + await using var source = new FakeSignalSource(); + await using var monitor = new CodexActivityMonitor(source); + var changes = new List(); + monitor.ActivityChanged += changes.Add; + await monitor.StartAsync(); + + source.Publish(new(CodexActivitySignalKind.TurnStarted, "session-a", "turn-a")); + source.Publish(new(CodexActivitySignalKind.TurnStarted, "session-b", "turn-b")); + source.Publish(new(CodexActivitySignalKind.TurnStopped, "session-a", "turn-a")); + + Assert.True(monitor.IsActive); + Assert.Equal([true], changes); + + source.Publish(new(CodexActivitySignalKind.TurnStopped, "session-b", "turn-b")); + + Assert.False(monitor.IsActive); + Assert.Equal([true, false], changes); + } + + [Fact] + public async Task UnknownStopIsNoOp() + { + await using var source = new FakeSignalSource(); + await using var monitor = new CodexActivityMonitor(source); + var changes = new List(); + monitor.ActivityChanged += changes.Add; + await monitor.StartAsync(); + + source.Publish(new(CodexActivitySignalKind.TurnStopped, "unknown", "unknown")); + + Assert.False(monitor.IsActive); + Assert.Empty(changes); + } + + [Fact] + public async Task SessionEndRemovesOnlyTurnsFromThatSession() + { + await using var source = new FakeSignalSource(); + await using var monitor = new CodexActivityMonitor(source); + var changes = new List(); + monitor.ActivityChanged += changes.Add; + await monitor.StartAsync(); + + source.Publish(new(CodexActivitySignalKind.TurnStarted, "session-a", "turn-a1")); + source.Publish(new(CodexActivitySignalKind.TurnStarted, "session-a", "turn-a2")); + source.Publish(new(CodexActivitySignalKind.TurnStarted, "session-b", "turn-b")); + source.Publish(new(CodexActivitySignalKind.SessionEnded, "session-a")); + + Assert.True(monitor.IsActive); + Assert.Equal([true], changes); + + source.Publish(new(CodexActivitySignalKind.SessionEnded, "session-b")); + + Assert.False(monitor.IsActive); + Assert.Equal([true, false], changes); + } + + private sealed class FakeSignalSource : ICodexActivitySignalSource + { + public event Action? SignalReceived; + + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Publish(CodexActivitySignal signal) => SignalReceived?.Invoke(signal); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/CodexUsageWidget.Tests/CodexActivityPipeTests.cs b/tests/CodexUsageWidget.Tests/CodexActivityPipeTests.cs new file mode 100644 index 0000000..b4911a2 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/CodexActivityPipeTests.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Codex.Hooks; + +namespace CodexUsageWidget.Tests; + +public sealed class CodexActivityPipeTests +{ + [Fact] + public async Task ServerReceivesStartAndStopSignals() + { + var pipeName = UniquePipeName(); + await using var source = new CodexActivityPipeSignalSource(pipeName); + var received = new List(); + var bothReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + source.SignalReceived += signal => + { + lock (received) + { + received.Add(signal); + if (received.Count == 2) + { + bothReceived.TrySetResult(); + } + } + }; + await source.StartAsync(); + + Assert.True(await CodexActivityPipeClient.TrySendAsync( + new(CodexActivitySignalKind.TurnStarted, "session", "turn"), + pipeName)); + Assert.True(await CodexActivityPipeClient.TrySendAsync( + new(CodexActivitySignalKind.TurnStopped, "session", "turn"), + pipeName)); + await bothReceived.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Collection( + received, + signal => Assert.Equal(CodexActivitySignalKind.TurnStarted, signal.Kind), + signal => Assert.Equal(CodexActivitySignalKind.TurnStopped, signal.Kind)); + } + + [Fact] + public async Task ClientWithoutServerFinishesQuickly() + { + var stopwatch = Stopwatch.StartNew(); + + var sent = await CodexActivityPipeClient.TrySendAsync( + new(CodexActivitySignalKind.TurnStarted, "session", "turn"), + UniquePipeName(), + connectTimeoutMilliseconds: 100); + + stopwatch.Stop(); + Assert.False(sent); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1), stopwatch.Elapsed.ToString()); + } + + [Fact] + public async Task CancellationStopsListenLoop() + { + var source = new CodexActivityPipeSignalSource(UniquePipeName()); + await source.StartAsync(); + + await source.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + } + + private static string UniquePipeName() => $"CodexUsageWidget.Tests.{Guid.NewGuid():N}"; +} diff --git a/tests/CodexUsageWidget.Tests/CodexHookConfigurationManagerTests.cs b/tests/CodexUsageWidget.Tests/CodexHookConfigurationManagerTests.cs new file mode 100644 index 0000000..9c63be5 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/CodexHookConfigurationManagerTests.cs @@ -0,0 +1,232 @@ +using System.Text.Json.Nodes; +using CodexUsageWidget.Infrastructure.Codex.Hooks; + +namespace CodexUsageWidget.Tests; + +public sealed class CodexHookConfigurationManagerTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "CodexUsageWidget.Tests", + Guid.NewGuid().ToString("N")); + + private string HooksPath => Path.Combine(_directory, "hooks.json"); + + private string ConfigPath => Path.Combine(_directory, "config.toml"); + + [Fact] + public void InstallCreatesNewHooksFile() + { + var manager = CreateManager(); + + var plan = manager.PlanInstall(WidgetPath()); + manager.Apply(plan); + + var hooks = JsonNode.Parse(File.ReadAllText(HooksPath))!["hooks"]!.AsObject(); + Assert.Single(hooks["UserPromptSubmit"]!.AsArray()); + Assert.Single(hooks["Stop"]!.AsArray()); + Assert.Single(hooks["SessionEnd"]!.AsArray()); + } + + [Fact] + public void InstallMergesExistingHooksAndUnknownFields() + { + Directory.CreateDirectory(_directory); + File.WriteAllText(HooksPath, """ + { + "description": "keep me", + "unknown": { "value": 42 }, + "hooks": { + "PreToolUse": [{ "hooks": [{ "type": "command", "command": "other" }] }] + } + } + """); + var manager = CreateManager(); + + manager.Apply(manager.PlanInstall(WidgetPath())); + + var root = JsonNode.Parse(File.ReadAllText(HooksPath))!.AsObject(); + Assert.Equal("keep me", root["description"]!.GetValue()); + Assert.Equal(42, root["unknown"]!["value"]!.GetValue()); + Assert.Single(root["hooks"]!["PreToolUse"]!.AsArray()); + } + + [Fact] + public void RepeatedInstallIsIdempotent() + { + var manager = CreateManager(); + manager.Apply(manager.PlanInstall(WidgetPath())); + + var secondPlan = manager.PlanInstall(WidgetPath()); + + Assert.False(secondPlan.HasChanges); + Assert.Null(secondPlan.Error); + } + + [Fact] + public void UninstallRemovesOnlyExactWidgetHandlers() + { + var manager = CreateManager(); + manager.Apply(manager.PlanInstall(WidgetPath())); + var root = JsonNode.Parse(File.ReadAllText(HooksPath))!.AsObject(); + root["hooks"]!["Stop"]!.AsArray().Add(new JsonObject + { + ["hooks"] = new JsonArray(new JsonObject + { + ["type"] = "command", + ["command"] = "other-widget --codex-activity-hook", + ["timeout"] = 1 + }) + }); + File.WriteAllText(HooksPath, root.ToJsonString()); + + manager.Apply(manager.PlanUninstall(WidgetPath())); + + var content = File.ReadAllText(HooksPath); + Assert.DoesNotContain( + CodexHookConfigurationManager.BuildHookCommand(WidgetPath()), + content, + StringComparison.Ordinal); + Assert.Contains("other-widget --codex-activity-hook", content, StringComparison.Ordinal); + } + + [Fact] + public void MalformedExistingJsonRemainsUntouched() + { + Directory.CreateDirectory(_directory); + const string Malformed = "{ not json"; + File.WriteAllText(HooksPath, Malformed); + var manager = CreateManager(); + + var plan = manager.PlanInstall(WidgetPath()); + + Assert.NotNull(plan.Error); + Assert.False(plan.HasChanges); + Assert.Equal(Malformed, File.ReadAllText(HooksPath)); + } + + [Fact] + public void HookCommandRunsThroughPowerShellFromAPowerShellHookShell() + { + var hookDirectory = Path.Combine(_directory, "Hook's Folder"); + Directory.CreateDirectory(hookDirectory); + var hookPath = Path.Combine(hookDirectory, "activity hook.cmd"); + File.WriteAllText( + hookPath, + "@echo off\r\nif not \"%~1\"==\"--codex-activity-hook\" exit /B 7\r\nexit /B 0\r\n"); + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-Command"); + startInfo.ArgumentList.Add(CodexHookConfigurationManager.BuildHookCommand(hookPath)); + + using var process = System.Diagnostics.Process.Start(startInfo)!; + process.WaitForExit(); + + Assert.Equal(0, process.ExitCode); + } + + [Fact] + public void HookCommandDoesNotSpawnNestedPowerShell() + { + var command = CodexHookConfigurationManager.BuildHookCommand(WidgetPath()); + + Assert.StartsWith("& '", command, StringComparison.Ordinal); + Assert.DoesNotContain("powershell.exe", command, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InstallReplacesLegacyDirectExecutableHandlers() + { + Directory.CreateDirectory(_directory); + var legacyCommand = $"\"{WidgetPath()}\" --codex-activity-hook"; + File.WriteAllText(HooksPath, $$""" + { + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(legacyCommand)!.ToJsonString()}}, "timeout": 1 }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(legacyCommand)!.ToJsonString()}}, "timeout": 1 }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(legacyCommand)!.ToJsonString()}}, "timeout": 1 }] }] + } + } + """); + + var manager = CreateManager(); + manager.Apply(manager.PlanInstall(WidgetPath())); + + var content = File.ReadAllText(HooksPath); + Assert.DoesNotContain(legacyCommand, content, StringComparison.Ordinal); + var hooks = JsonNode.Parse(content)!["hooks"]!.AsObject(); + foreach (var eventName in new[] { "UserPromptSubmit", "Stop", "SessionEnd" }) + { + var groups = hooks[eventName]!.AsArray(); + var handler = groups.Single()!["hooks"]!.AsArray().Single()!; + Assert.Equal( + CodexHookConfigurationManager.BuildHookCommand(WidgetPath()), + handler["command"]!.GetValue()); + Assert.Equal(3, handler["timeout"]!.GetValue()); + } + } + + [Fact] + public void InstallReplacesNestedPowerShellHandlers() + { + Directory.CreateDirectory(_directory); + var escapedPath = WidgetPath().Replace("'", "''", StringComparison.Ordinal); + var nestedCommand = "powershell.exe -NoLogo -NoProfile -NonInteractive " + + "-ExecutionPolicy Bypass -Command " + + $"\"& '{escapedPath}' --codex-activity-hook\""; + File.WriteAllText(HooksPath, $$""" + { + "hooks": { + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(nestedCommand)!.ToJsonString()}}, "timeout": 3 }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(nestedCommand)!.ToJsonString()}}, "timeout": 3 }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", "command": {{JsonValue.Create(nestedCommand)!.ToJsonString()}}, "timeout": 3 }] }] + } + } + """); + + var manager = CreateManager(); + manager.Apply(manager.PlanInstall(WidgetPath())); + + var content = File.ReadAllText(HooksPath); + Assert.DoesNotContain("powershell.exe", content, StringComparison.OrdinalIgnoreCase); + var hooks = JsonNode.Parse(content)!["hooks"]!.AsObject(); + foreach (var eventName in new[] { "UserPromptSubmit", "Stop", "SessionEnd" }) + { + var handler = hooks[eventName]!.AsArray().Single()!["hooks"]!.AsArray().Single()!; + Assert.Equal( + CodexHookConfigurationManager.BuildHookCommand(WidgetPath()), + handler["command"]!.GetValue()); + } + } + + [Fact] + public void ExplicitlyDisabledHooksProduceActionableError() + { + Directory.CreateDirectory(_directory); + File.WriteAllText(ConfigPath, "[features]\nhooks = false\n"); + + var plan = CreateManager().PlanInstall(WidgetPath()); + + Assert.Contains("explicitly disabled", plan.Error, StringComparison.Ordinal); + Assert.False(plan.HasChanges); + } + + private CodexHookConfigurationManager CreateManager() => new(HooksPath, ConfigPath); + + private string WidgetPath() => Path.Combine(_directory, "Widget Folder", "CodexUsageWidget.exe"); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} From 5161c009e65dea3bf713a18b921a389664e7acdf Mon Sep 17 00:00:00 2001 From: ognjeeen Date: Fri, 7 Aug 2026 13:19:51 +0200 Subject: [PATCH 2/8] feat: add activity hook setup UI --- README.md | 27 +- docs/ARCHITECTURE.md | 15 +- src/CodexUsageWidget/App.xaml.cs | 11 +- .../Application/ActivityHookSetupModels.cs | 27 ++ .../Application/IActivityHookSetupService.cs | 10 + .../Application/ICodexLauncher.cs | 6 + .../Infrastructure/Codex/CodexCliLauncher.cs | 27 ++ .../Hooks/CodexActivityHookSetupService.cs | 121 +++++++ .../Hooks/CodexHookConfigurationManager.cs | 44 ++- .../Codex/Hooks/CodexHookTrustStatusParser.cs | 78 +++++ .../Infrastructure/Windows/TrayIconService.cs | 6 + .../Views/ActivityHookChangeReviewWindow.xaml | 77 +++++ .../ActivityHookChangeReviewWindow.xaml.cs | 38 +++ .../Views/ActivityHookSetupWindow.xaml | 139 ++++++++ .../Views/ActivityHookSetupWindow.xaml.cs | 184 +++++++++++ .../ActivityHookSetupWindowController.cs | 57 ++++ .../Views/Controls/DetailedUsageView.xaml | 2 +- src/CodexUsageWidget/Views/MainWindow.xaml | 14 + src/CodexUsageWidget/Views/MainWindow.xaml.cs | 34 +- .../Views/Resources/WidgetTheme.xaml | 311 ++++++++++++++++++ .../Views/TaskbarLabelWindow.xaml | 1 + .../Views/TaskbarLabelWindow.xaml.cs | 5 + .../ViewModels/ActivityHookSetupViewModel.cs | 113 +++++++ .../CodexActivityHookSetupServiceTests.cs | 182 ++++++++++ .../CodexHookConfigurationManagerTests.cs | 4 + 25 files changed, 1510 insertions(+), 23 deletions(-) create mode 100644 src/CodexUsageWidget/Application/ActivityHookSetupModels.cs create mode 100644 src/CodexUsageWidget/Application/IActivityHookSetupService.cs create mode 100644 src/CodexUsageWidget/Application/ICodexLauncher.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/CodexCliLauncher.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookTrustStatusParser.cs create mode 100644 src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml create mode 100644 src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml.cs create mode 100644 src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml create mode 100644 src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs create mode 100644 src/CodexUsageWidget/Views/ActivityHookSetupWindowController.cs create mode 100644 src/CodexUsageWidget/Views/ViewModels/ActivityHookSetupViewModel.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexActivityHookSetupServiceTests.cs diff --git a/README.md b/README.md index 0331d1d..bf8cd52 100644 --- a/README.md +++ b/README.md @@ -75,21 +75,32 @@ icon to refresh, change display mode, or exit. ## Codex activity hooks -The taskbar dots can react to real Codex work without polling. Hook installation is +The taskbar dots can react to real Codex work without polling. Hook installation remains an explicit, reviewable action and is never performed during normal widget startup. -From PowerShell in the directory containing the widget executable, run: + +Select the three-dot activity button in the desktop widget, or choose **Activity dots...** +from the tray or taskbar-label menu. The setup window reports whether the hooks are missing, +awaiting approval, active, modified, or disabled. Select **Install hooks**, review the exact +proposed `~/.codex/hooks.json` content, and confirm the change. + +After installation, select **Copy /hooks and open Codex**. Paste `/hooks` into Codex, then +review and trust the exact new `UserPromptSubmit`, `Stop`, and `SessionEnd` definitions. +New or changed definitions require new trust. Return to the setup window and select +**Check again** to verify that activity reporting is ready. + +The setup window can also remove only handlers that exactly match the current widget +executable. Existing hooks and unknown configuration fields are preserved. + +For scripted setup or recovery, the existing command-line flow remains available. From +PowerShell in the directory containing the widget executable, run: ```powershell .\CodexUsageWidget.exe --install-activity-hooks ``` -The command displays the proposed `~/.codex/hooks.json` content and writes it only -after interactive confirmation. It preserves existing hooks and unknown fields. -After installation, start Codex, open `/hooks`, and review and trust the exact new -`UserPromptSubmit`, `Stop`, and `SessionEnd` definitions. New or changed definitions -require new trust. +The command displays the proposed content and writes it only after interactive confirmation. -To remove only handlers that exactly match the current widget executable, run: +To perform the equivalent removal from PowerShell, run: ```powershell .\CodexUsageWidget.exe --uninstall-activity-hooks diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7de2f04..68bf869 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,8 +21,8 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence ## Runtime flow -1. `App` handles activity-hook/configuration command modes before acquiring the - single-instance mutex, then constructs the normal widget object graph. +1. `Program` handles activity-hook/configuration command modes before WPF startup; + `App` then acquires the single-instance mutex and constructs the normal widget object graph. 2. `UsageMonitor` owns refresh scheduling, timeout handling and refresh coalescing. 3. `CodexUsageProvider` coordinates required rate-limit reads and optional token-activity reads. 4. `CodexAppServerSession` owns initialized app-server connection lifetime. @@ -31,8 +31,12 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence 7. `CodexActivityPipeSignalSource` receives minimal lifecycle signals over a current-user-only named pipe; `CodexActivityMonitor` owns the active turn set and emits only final boolean transitions. -8. `UsageWidgetViewModel` maps snapshots to immutable presentation state. -9. `MainWindow` remains a window-lifecycle shell while focused user controls render +8. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads + trust state through `hooks/list`; `CodexHookTrustStatusParser` owns the protocol shape. +9. `ActivityHookSetupWindow` presents setup status while a separate review dialog shows + the exact proposed file content before installation or removal. +10. `UsageWidgetViewModel` maps snapshots to immutable presentation state. +11. `MainWindow` remains a window-lifecycle shell while focused user controls render compact, detailed, and repeated limit-row content. ## Dependency direction @@ -52,6 +56,9 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence - A semaphore prevents concurrent refreshes and a mutex prevents duplicate apps. - Activity hook IPC is bounded and local to the current Windows user. Duplicate turn lifecycle events are idempotent and session end removes only that session's turns. +- UI hook setup reuses the same compare-before-write configuration plan as the CLI flow. + Codex remains the owner of hook trust; the widget only reads trust state and opens the + interactive CLI for the user's explicit `/hooks` approval. - Activity state is not persisted or reconstructed with polling. Missing cleanup after a hard Codex crash is cleared by restarting the widget. - Unhandled exceptions and CLI diagnostics are recorded locally for support. diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index 6093700..65c7219 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -35,15 +35,24 @@ protected override void OnStartup(StartupEventArgs e) CodexActivityMonitor? activityMonitor = null; try { - var usageProvider = new CodexUsageProvider(new CodexAppServerSession()); + var appServerSession = new CodexAppServerSession(); + var usageProvider = new CodexUsageProvider(appServerSession); var usageMonitor = new UsageMonitor(usageProvider); usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message); activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource()); + var processPath = Environment.ProcessPath ?? + throw new InvalidOperationException("Cannot determine the widget executable path."); + var activityHookSetupService = new CodexActivityHookSetupService( + new CodexHookConfigurationManager(), + appServerSession, + processPath); var window = new MainWindow( usageMonitor, activityMonitor, + activityHookSetupService, + new CodexCliLauncher(), new DisplayModeStore(), new WidgetDensityStore(), new TrayIconService()); diff --git a/src/CodexUsageWidget/Application/ActivityHookSetupModels.cs b/src/CodexUsageWidget/Application/ActivityHookSetupModels.cs new file mode 100644 index 0000000..06d5f9e --- /dev/null +++ b/src/CodexUsageWidget/Application/ActivityHookSetupModels.cs @@ -0,0 +1,27 @@ +namespace CodexUsageWidget.Application; + +public enum ActivityHookSetupState +{ + NotInstalled, + ApprovalRequired, + Active, + Modified, + HooksDisabled, + InstalledStatusUnavailable, + Error +} + +public enum ActivityHookChangeKind +{ + Install, + Uninstall +} + +public sealed record ActivityHookSetupStatus( + ActivityHookSetupState State, + string? Detail = null); + +public sealed record ActivityHookChangePreview( + ActivityHookChangeKind Kind, + bool HasChanges, + string ProposedContent); diff --git a/src/CodexUsageWidget/Application/IActivityHookSetupService.cs b/src/CodexUsageWidget/Application/IActivityHookSetupService.cs new file mode 100644 index 0000000..1e5324a --- /dev/null +++ b/src/CodexUsageWidget/Application/IActivityHookSetupService.cs @@ -0,0 +1,10 @@ +namespace CodexUsageWidget.Application; + +public interface IActivityHookSetupService +{ + Task GetStatusAsync(CancellationToken cancellationToken = default); + + ActivityHookChangePreview PrepareChange(ActivityHookChangeKind kind); + + void ApplyChange(ActivityHookChangePreview preview); +} diff --git a/src/CodexUsageWidget/Application/ICodexLauncher.cs b/src/CodexUsageWidget/Application/ICodexLauncher.cs new file mode 100644 index 0000000..1acb37c --- /dev/null +++ b/src/CodexUsageWidget/Application/ICodexLauncher.cs @@ -0,0 +1,6 @@ +namespace CodexUsageWidget.Application; + +public interface ICodexLauncher +{ + void OpenInteractive(); +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/CodexCliLauncher.cs b/src/CodexUsageWidget/Infrastructure/Codex/CodexCliLauncher.cs new file mode 100644 index 0000000..e41d48c --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/CodexCliLauncher.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex; + +public sealed class CodexCliLauncher : ICodexLauncher +{ + public void OpenInteractive() + { + var startInfo = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe", + UseShellExecute = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + WindowStyle = ProcessWindowStyle.Normal + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/k"); + startInfo.ArgumentList.Add("call"); + startInfo.ArgumentList.Add(CodexExecutableLocator.Resolve()); + + if (Process.Start(startInfo) is null) + { + throw new InvalidOperationException("Windows could not open Codex CLI."); + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs new file mode 100644 index 0000000..0fd0984 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs @@ -0,0 +1,121 @@ +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public sealed class CodexActivityHookSetupService : IActivityHookSetupService +{ + private readonly CodexHookConfigurationManager _configurationManager; + private readonly ICodexAppServerSession _session; + private readonly string _processPath; + private readonly string _workingDirectory; + + public CodexActivityHookSetupService( + CodexHookConfigurationManager configurationManager, + ICodexAppServerSession session, + string processPath, + string? workingDirectory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(processPath); + ArgumentNullException.ThrowIfNull(configurationManager); + ArgumentNullException.ThrowIfNull(session); + _configurationManager = configurationManager; + _session = session; + _processPath = processPath; + _workingDirectory = workingDirectory ?? Environment.CurrentDirectory; + } + + public async Task GetStatusAsync( + CancellationToken cancellationToken = default) + { + var configurationPlan = _configurationManager.PlanInstall(_processPath); + if (configurationPlan.Error is not null) + { + return new ActivityHookSetupStatus( + configurationPlan.ErrorKind == CodexHookConfigurationErrorKind.HooksDisabled + ? ActivityHookSetupState.HooksDisabled + : ActivityHookSetupState.Error, + configurationPlan.Error); + } + + if (configurationPlan.HasChanges) + { + return new ActivityHookSetupStatus(ActivityHookSetupState.NotInstalled); + } + + try + { + var result = await _session.RequestAsync( + "hooks/list", + new { cwds = new[] { _workingDirectory } }, + cancellationToken) + .ConfigureAwait(false); + return FromTrustEvaluation(CodexHookTrustStatusParser.Parse( + result, + CodexHookConfigurationManager.BuildHookCommand(_processPath))); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new ActivityHookSetupStatus( + ActivityHookSetupState.InstalledStatusUnavailable, + $"Codex could not report hook trust status: {ex.Message}"); + } + } + + public ActivityHookChangePreview PrepareChange(ActivityHookChangeKind kind) + { + var plan = CreatePlan(kind); + EnsureValid(plan); + return new ActivityHookChangePreview(kind, plan.HasChanges, plan.ProposedContent); + } + + public void ApplyChange(ActivityHookChangePreview preview) + { + ArgumentNullException.ThrowIfNull(preview); + var currentPlan = CreatePlan(preview.Kind); + EnsureValid(currentPlan); + + if (currentPlan.HasChanges != preview.HasChanges || + !string.Equals( + currentPlan.ProposedContent, + preview.ProposedContent, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Codex hooks changed after the preview. Review the updated change and try again."); + } + + _configurationManager.Apply(currentPlan); + } + + private CodexHookConfigurationPlan CreatePlan(ActivityHookChangeKind kind) => + kind == ActivityHookChangeKind.Install + ? _configurationManager.PlanInstall(_processPath) + : _configurationManager.PlanUninstall(_processPath); + + private static void EnsureValid(CodexHookConfigurationPlan plan) + { + if (plan.Error is not null) + { + throw new InvalidOperationException(plan.Error); + } + } + + private static ActivityHookSetupStatus FromTrustEvaluation( + CodexHookTrustEvaluation evaluation) => + evaluation switch + { + CodexHookTrustEvaluation.ApprovalRequired => + new ActivityHookSetupStatus(ActivityHookSetupState.ApprovalRequired), + CodexHookTrustEvaluation.Active => + new ActivityHookSetupStatus(ActivityHookSetupState.Active), + CodexHookTrustEvaluation.Modified => + new ActivityHookSetupStatus(ActivityHookSetupState.Modified), + _ => new ActivityHookSetupStatus( + ActivityHookSetupState.InstalledStatusUnavailable, + "The hook definitions are installed, but Codex did not report all expected hooks.") + }; +} diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs index 99f548e..563c3da 100644 --- a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; @@ -11,7 +12,11 @@ public sealed partial class CodexHookConfigurationManager private const int HookTimeoutSeconds = 3; private const int LegacyHookTimeoutSeconds = 1; private static readonly string[] ActivityEvents = ["UserPromptSubmit", "Stop", "SessionEnd"]; - private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions IndentedJson = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; private readonly string _hooksPath; private readonly string _configPath; @@ -29,13 +34,15 @@ public CodexHookConfigurationPlan PlanInstall(string processPath) { if (string.IsNullOrWhiteSpace(processPath) || !Path.IsPathFullyQualified(processPath)) { - return ErrorPlan("The widget executable path must be absolute."); + return ErrorPlan( + "The widget executable path must be absolute.", + CodexHookConfigurationErrorKind.InvalidProcessPath); } var featureError = GetDisabledFeatureError(); if (featureError is not null) { - return ErrorPlan(featureError); + return ErrorPlan(featureError, CodexHookConfigurationErrorKind.HooksDisabled); } return PlanChange(processPath, install: true); @@ -45,7 +52,9 @@ public CodexHookConfigurationPlan PlanUninstall(string processPath) { if (string.IsNullOrWhiteSpace(processPath) || !Path.IsPathFullyQualified(processPath)) { - return ErrorPlan("The widget executable path must be absolute."); + return ErrorPlan( + "The widget executable path must be absolute.", + CodexHookConfigurationErrorKind.InvalidProcessPath); } return PlanChange(processPath, install: false); @@ -131,6 +140,7 @@ private CodexHookConfigurationPlan PlanChange(string processPath, bool install) { return ErrorPlan( $"Cannot safely modify '{_hooksPath}': {ex.Message}", + CodexHookConfigurationErrorKind.InvalidConfiguration, originalExisted, originalContent); } @@ -139,6 +149,7 @@ private CodexHookConfigurationPlan PlanChange(string processPath, bool install) { return ErrorPlan( $"Cannot safely modify '{_hooksPath}': 'hooks' is not a JSON object.", + CodexHookConfigurationErrorKind.InvalidConfiguration, originalExisted, originalContent); } @@ -164,6 +175,7 @@ private CodexHookConfigurationPlan PlanChange(string processPath, bool install) { return ErrorPlan( $"Cannot safely modify '{_hooksPath}': hooks.{eventName} is not a JSON array.", + CodexHookConfigurationErrorKind.InvalidConfiguration, originalExisted, originalContent); } @@ -217,7 +229,8 @@ private CodexHookConfigurationPlan PlanChange(string processPath, bool install) proposedContent, error: null, originalExisted, - originalContent); + originalContent, + CodexHookConfigurationErrorKind.None); } private string? GetDisabledFeatureError() @@ -330,10 +343,12 @@ private static CodexHookConfigurationPlan NoChangePlan( proposedContent: originalContent ?? string.Empty, error: null, originalExisted, - originalContent); + originalContent, + CodexHookConfigurationErrorKind.None); private static CodexHookConfigurationPlan ErrorPlan( string error, + CodexHookConfigurationErrorKind errorKind, bool originalExisted = false, string? originalContent = null) => new( @@ -341,7 +356,8 @@ private static CodexHookConfigurationPlan ErrorPlan( proposedContent: originalContent ?? string.Empty, error, originalExisted, - originalContent); + originalContent, + errorKind); [GeneratedRegex(@"^\s*\[[^\]]+\]\s*(?:#.*)?$")] private static partial Regex TomlSectionRegex(); @@ -353,6 +369,14 @@ private static CodexHookConfigurationPlan ErrorPlan( private static partial Regex DisabledHooksRegex(); } +public enum CodexHookConfigurationErrorKind +{ + None, + HooksDisabled, + InvalidProcessPath, + InvalidConfiguration +} + public sealed class CodexHookConfigurationPlan { internal CodexHookConfigurationPlan( @@ -360,13 +384,15 @@ internal CodexHookConfigurationPlan( string proposedContent, string? error, bool originalExisted, - string? originalContent) + string? originalContent, + CodexHookConfigurationErrorKind errorKind) { HasChanges = hasChanges; ProposedContent = proposedContent; Error = error; OriginalExisted = originalExisted; OriginalContent = originalContent; + ErrorKind = errorKind; } public bool HasChanges { get; } @@ -375,6 +401,8 @@ internal CodexHookConfigurationPlan( public string? Error { get; } + public CodexHookConfigurationErrorKind ErrorKind { get; } + internal bool OriginalExisted { get; } internal string? OriginalContent { get; } diff --git a/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookTrustStatusParser.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookTrustStatusParser.cs new file mode 100644 index 0000000..f92c7f3 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookTrustStatusParser.cs @@ -0,0 +1,78 @@ +using System.Text.Json; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +internal static class CodexHookTrustStatusParser +{ + private static readonly string[] RequiredEvents = ["userPromptSubmit", "stop", "sessionEnd"]; + + public static CodexHookTrustEvaluation Parse(JsonElement result, string expectedCommand) + { + if (!result.TryGetProperty("data", out var entries) || + entries.ValueKind != JsonValueKind.Array) + { + return CodexHookTrustEvaluation.Unavailable; + } + + var matchingHooks = entries + .EnumerateArray() + .SelectMany(ReadHooks) + .Where(hook => + ReadString(hook, "command") is { } command && + string.Equals(command, expectedCommand, StringComparison.Ordinal)) + .ToArray(); + + var requiredHooks = RequiredEvents + .Select(eventName => matchingHooks.FirstOrDefault(hook => + string.Equals(ReadString(hook, "eventName"), eventName, StringComparison.Ordinal))) + .ToArray(); + if (requiredHooks.Any(hook => hook.ValueKind == JsonValueKind.Undefined)) + { + return CodexHookTrustEvaluation.Unavailable; + } + + var statuses = requiredHooks + .Select(hook => ReadString(hook, "trustStatus")) + .ToArray(); + if (statuses.Any(status => string.Equals(status, "modified", StringComparison.Ordinal))) + { + return CodexHookTrustEvaluation.Modified; + } + + if (statuses.Any(status => string.Equals(status, "untrusted", StringComparison.Ordinal))) + { + return CodexHookTrustEvaluation.ApprovalRequired; + } + + return statuses.All(status => + string.Equals(status, "trusted", StringComparison.Ordinal) || + string.Equals(status, "managed", StringComparison.Ordinal)) + ? CodexHookTrustEvaluation.Active + : CodexHookTrustEvaluation.Unavailable; + } + + private static IEnumerable ReadHooks(JsonElement entry) + { + if (!entry.TryGetProperty("hooks", out var hooks) || + hooks.ValueKind != JsonValueKind.Array) + { + return []; + } + + return hooks.EnumerateArray().Select(hook => hook.Clone()); + } + + private static string? ReadString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} + +internal enum CodexHookTrustEvaluation +{ + Unavailable, + ApprovalRequired, + Active, + Modified +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index a76ce4c..143fc6c 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -16,6 +16,10 @@ public TrayIconService() var menu = new Forms.ContextMenuStrip(); menu.Items.Add("Open", null, (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add("Refresh", null, (_, _) => RefreshRequested?.Invoke(this, EventArgs.Empty)); + menu.Items.Add( + "Activity dots...", + null, + (_, _) => ActivityDotsSetupRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add(new Forms.ToolStripSeparator()); var displayModeMenu = new Forms.ToolStripMenuItem("Display mode"); @@ -48,6 +52,8 @@ public TrayIconService() public event EventHandler? RefreshRequested; + public event EventHandler? ActivityDotsSetupRequested; + public event EventHandler? DesktopModeRequested; public event EventHandler? TaskbarModeRequested; diff --git a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml new file mode 100644 index 0000000..61c3916 --- /dev/null +++ b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +