diff --git a/README.md b/README.md index 43e8a44..14236aa 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,76 @@ 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. +## Live Codex activity dots + +Activity dots turn the official local Codex lifecycle hooks into an at-a-glance signal +that work is still running. They are available in both the taskbar label and desktop +widget, without polling Codex or estimating activity from rate-limit changes. + +### What activity dots provide + +- One quiet dot while Codex is idle, expanding into a three-dot wave during active work +- Immediate, event-driven updates when a Codex turn starts or finishes +- Independent tracking of parallel turns, so one completed turn cannot hide another + turn that is still running +- A completion animation only after the final active turn finishes +- A temporary taskbar preview for checking the animation without changing saved settings +- A dedicated setup window for installation status, trust approval, refresh, and removal + +### Private and local by design + +- No prompts, responses, transcript contents, transcript paths, or model output are + collected, stored, forwarded, or logged +- No telemetry, analytics, browser automation, remote backend, or credential access is used +- Hook signals stay on the current Windows account through a current-user-only named pipe +- Only the lifecycle event type and the Codex-provided session and turn identifiers are + passed to the in-memory activity monitor +- Activity state is not persisted, so the widget does not build a history of your work +- Authentication remains entirely owned by the locally installed Codex CLI + +Hook installation remains an explicit, reviewable action and is never performed during +normal widget startup. + +### Setup and removal + +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 remove handlers generated by the current widget and conservatively +recognized handlers from earlier Codex Usage Widget portable locations. Recognition is limited +to the exact command formats historically generated for `CodexUsageWidget.exe`; similarly named +handlers from other applications, 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 content and writes it only after interactive confirmation. + +To perform the equivalent removal from PowerShell, run: + +```powershell +.\CodexUsageWidget.exe --uninstall-activity-hooks +``` + +If the widget is closed, the hook 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..f2d0d47 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,22 @@ 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. `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. 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. `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 @@ -46,6 +54,15 @@ 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. Accepted clients are + consumed in order with a per-client read timeout, while separate pipe instances keep parallel + Codex sessions connectable. 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. - Publish trimming is disabled because WPF is not a safe trimming boundary. diff --git a/docs/images/detailed-widget.png b/docs/images/detailed-widget.png index 41ddd53..bc514d1 100644 Binary files a/docs/images/detailed-widget.png and b/docs/images/detailed-widget.png differ diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index e92cbad..65c7219 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,32 @@ 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 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()); MainWindow = window; + activityMonitor.StartAsync().GetAwaiter().GetResult(); window.Show(); if (window.StartsInTaskbarIndicatorMode) { @@ -53,6 +68,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/ActivityHookSetupModels.cs b/src/CodexUsageWidget/Application/ActivityHookSetupModels.cs new file mode 100644 index 0000000..c94e2fe --- /dev/null +++ b/src/CodexUsageWidget/Application/ActivityHookSetupModels.cs @@ -0,0 +1,28 @@ +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, + bool HasInstalledHandlers = false); + +public sealed record ActivityHookChangePreview( + ActivityHookChangeKind Kind, + bool HasChanges, + string ProposedContent); diff --git a/src/CodexUsageWidget/Application/CodexActivityMonitor.cs b/src/CodexUsageWidget/Application/CodexActivityMonitor.cs new file mode 100644 index 0000000..5c80bad --- /dev/null +++ b/src/CodexUsageWidget/Application/CodexActivityMonitor.cs @@ -0,0 +1,84 @@ +namespace CodexUsageWidget.Application; + +public sealed class CodexActivityMonitor : IAsyncDisposable +{ + private readonly object _stateLock = new(); + private readonly object _transitionLock = 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) + { + lock (_transitionLock) + { + 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/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/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/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/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/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/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/CodexActivityHookSetupService.cs b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs new file mode 100644 index 0000000..486bbea --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityHookSetupService.cs @@ -0,0 +1,131 @@ +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) + { + var hooksDisabled = configurationPlan.ErrorKind == + CodexHookConfigurationErrorKind.HooksDisabled; + var hasInstalledHandlers = false; + if (hooksDisabled) + { + var uninstallPlan = _configurationManager.PlanUninstall(_processPath); + hasInstalledHandlers = uninstallPlan.Error is null && uninstallPlan.HasChanges; + } + + return new ActivityHookSetupStatus( + hooksDisabled + ? ActivityHookSetupState.HooksDisabled + : ActivityHookSetupState.Error, + configurationPlan.Error, + hasInstalledHandlers); + } + + 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/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..4210795 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexActivityPipeSignalSource.cs @@ -0,0 +1,192 @@ +using System.Buffers.Binary; +using System.IO; +using System.IO.Pipes; +using System.Text.Json; +using System.Threading.Channels; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex.Hooks; + +public sealed class CodexActivityPipeSignalSource : ICodexActivitySignalSource +{ + private const int MaximumPayloadBytes = 4096; + private const int MaximumIdentifierLength = 256; + private const int DefaultReadTimeoutMilliseconds = 1000; + + private readonly string _pipeName; + private readonly TimeSpan _readTimeout; + private readonly CancellationTokenSource _lifetime = new(); + private readonly Channel _acceptedClients = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false + }); + private Task? _listenTask; + private Task? _processTask; + private int _disposed; + + public CodexActivityPipeSignalSource( + string pipeName = CodexActivityPipeClient.DefaultPipeName, + int readTimeoutMilliseconds = DefaultReadTimeoutMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(readTimeoutMilliseconds); + _pipeName = pipeName; + _readTimeout = TimeSpan.FromMilliseconds(readTimeoutMilliseconds); + } + + public event Action? SignalReceived; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(_disposed != 0, this); + _processTask ??= ProcessClientsAsync(_lifetime.Token); + _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; + } + + if (!_acceptedClients.Writer.TryWrite(pipe)) + { + pipe.Dispose(); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + _acceptedClients.Writer.TryComplete(); + } + } + + private NamedPipeServerStream CreateServer() => new( + _pipeName, + PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, + MaximumPayloadBytes, + MaximumPayloadBytes); + + private async Task ProcessClientsAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var pipe in _acceptedClients.Reader + .ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) + { + using (pipe) + { + using var readTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readTimeout.CancelAfter(_readTimeout); + try + { + var signal = await ReadSignalAsync(pipe, readTimeout.Token) + .ConfigureAwait(false); + if (signal is not null) + { + SignalReceived?.Invoke(signal); + } + } + catch (OperationCanceledException) when (readTimeout.IsCancellationRequested) + { + } + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + while (_acceptedClients.Reader.TryRead(out var pipe)) + { + pipe.Dispose(); + } + } + } + + 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() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await _lifetime.CancelAsync().ConfigureAwait(false); + if (_listenTask is not null) + { + await _listenTask.ConfigureAwait(false); + } + + if (_processTask is not null) + { + await _processTask.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..fbf6495 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookConfigurationManager.cs @@ -0,0 +1,473 @@ +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +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 const string HookArgument = "--codex-activity-hook"; + private const string WidgetExecutableName = "CodexUsageWidget.exe"; + private const string NestedPowerShellPrefix = + "powershell.exe -NoLogo -NoProfile -NonInteractive " + + "-ExecutionPolicy Bypass -Command \"& '"; + private static readonly string[] ActivityEvents = ["UserPromptSubmit", "Stop", "SessionEnd"]; + private static readonly JsonSerializerOptions IndentedJson = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + 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.", + CodexHookConfigurationErrorKind.InvalidProcessPath); + } + + var featureError = GetDisabledFeatureError(); + if (featureError is not null) + { + return ErrorPlan(featureError, CodexHookConfigurationErrorKind.HooksDisabled); + } + + 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.", + CodexHookConfigurationErrorKind.InvalidProcessPath); + } + + 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 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}", + CodexHookConfigurationErrorKind.InvalidConfiguration, + 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.", + CodexHookConfigurationErrorKind.InvalidConfiguration, + 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); + 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.", + CodexHookConfigurationErrorKind.InvalidConfiguration, + originalExisted, + originalContent); + } + + var groups = hooks[eventName] as JsonArray; + if (install) + { + groups ??= new JsonArray(); + hooks[eventName] = groups; + var recognizedCount = CountRecognizedHandlers(groups); + var currentCount = CountExactHandlers(groups, command); + if (recognizedCount != 1 || currentCount != 1) + { + RemoveRecognizedHandlers(groups); + groups.Add(new JsonObject + { + ["hooks"] = new JsonArray(CreateHandler(command)) + }); + changed = true; + } + } + else if (groups is not null) + { + changed |= RemoveRecognizedHandlers(groups); + } + } + + if (!changed) + { + return NoChangePlan(originalExisted, originalContent); + } + + var proposedContent = root.ToJsonString(IndentedJson) + Environment.NewLine; + return new CodexHookConfigurationPlan( + hasChanges: true, + proposedContent, + error: null, + originalExisted, + originalContent, + CodexHookConfigurationErrorKind.None); + } + + 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 int CountExactHandlers(JsonArray groups, string command) + { + var expected = CreateHandler(command); + return groups + .OfType() + .Select(group => group["hooks"]) + .OfType() + .SelectMany(handlers => handlers) + .Count(handler => JsonNode.DeepEquals(handler, expected)); + } + + private static int CountRecognizedHandlers(JsonArray groups) => + groups + .OfType() + .Select(group => group["hooks"]) + .OfType() + .SelectMany(handlers => handlers) + .Count(IsRecognizedWidgetHandler); + + private static bool RemoveRecognizedHandlers(JsonArray groups) + { + var changed = false; + for (var groupIndex = groups.Count - 1; groupIndex >= 0; groupIndex--) + { + if (groups[groupIndex] is not JsonObject group || + group["hooks"] is not JsonArray handlers) + { + continue; + } + + var removedFromGroup = false; + for (var index = handlers.Count - 1; index >= 0; index--) + { + if (IsRecognizedWidgetHandler(handlers[index])) + { + handlers.RemoveAt(index); + changed = true; + removedFromGroup = true; + } + } + + if (removedFromGroup && handlers.Count == 0 && group.Count == 1) + { + groups.RemoveAt(groupIndex); + } + } + + return changed; + } + + private static bool IsRecognizedWidgetHandler(JsonNode? handler) + { + if (handler is not JsonObject handlerObject || + handlerObject["command"] is not JsonValue commandValue || + !commandValue.TryGetValue(out var command) || + !TryGetGeneratedWidgetCommandTimeout(command, out var timeoutSeconds)) + { + return false; + } + + return JsonNode.DeepEquals(handler, CreateHandler(command, timeoutSeconds)); + } + + private static bool TryGetGeneratedWidgetCommandTimeout( + string command, + out int timeoutSeconds) + { + if (TryReadSingleQuotedPath( + command, + "& '", + $"' {HookArgument}", + out var processPath) || + TryReadSingleQuotedPath( + command, + NestedPowerShellPrefix, + $"' {HookArgument}\"", + out processPath)) + { + timeoutSeconds = HookTimeoutSeconds; + return IsWidgetExecutablePath(processPath); + } + + const string LegacyPrefix = "\""; + var legacySuffix = $"\" {HookArgument}"; + if (command.StartsWith(LegacyPrefix, StringComparison.Ordinal) && + command.EndsWith(legacySuffix, StringComparison.Ordinal) && + command.Length > LegacyPrefix.Length + legacySuffix.Length) + { + var pathLength = command.Length - LegacyPrefix.Length - legacySuffix.Length; + processPath = command.Substring(LegacyPrefix.Length, pathLength); + if (!processPath.Contains('"', StringComparison.Ordinal)) + { + timeoutSeconds = LegacyHookTimeoutSeconds; + return IsWidgetExecutablePath(processPath); + } + } + + timeoutSeconds = 0; + return false; + } + + private static bool TryReadSingleQuotedPath( + string command, + string prefix, + string suffix, + out string processPath) + { + processPath = string.Empty; + if (!command.StartsWith(prefix, StringComparison.Ordinal) || + !command.EndsWith(suffix, StringComparison.Ordinal) || + command.Length <= prefix.Length + suffix.Length) + { + return false; + } + + var escapedPath = command.AsSpan( + prefix.Length, + command.Length - prefix.Length - suffix.Length); + var path = new StringBuilder(escapedPath.Length); + for (var index = 0; index < escapedPath.Length; index++) + { + if (escapedPath[index] != '\'') + { + path.Append(escapedPath[index]); + continue; + } + + if (index + 1 >= escapedPath.Length || escapedPath[index + 1] != '\'') + { + return false; + } + + path.Append('\''); + index++; + } + + processPath = path.ToString(); + return true; + } + + private static bool IsWidgetExecutablePath(string processPath) => + Path.IsPathFullyQualified(processPath) && + string.Equals( + Path.GetFileName(processPath), + WidgetExecutableName, + StringComparison.OrdinalIgnoreCase); + + 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, + CodexHookConfigurationErrorKind.None); + + private static CodexHookConfigurationPlan ErrorPlan( + string error, + CodexHookConfigurationErrorKind errorKind, + bool originalExisted = false, + string? originalContent = null) => + new( + hasChanges: false, + proposedContent: originalContent ?? string.Empty, + error, + originalExisted, + originalContent, + errorKind); + + [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 enum CodexHookConfigurationErrorKind +{ + None, + HooksDisabled, + InvalidProcessPath, + InvalidConfiguration +} + +public sealed class CodexHookConfigurationPlan +{ + internal CodexHookConfigurationPlan( + bool hasChanges, + string proposedContent, + string? error, + bool originalExisted, + string? originalContent, + CodexHookConfigurationErrorKind errorKind) + { + HasChanges = hasChanges; + ProposedContent = proposedContent; + Error = error; + OriginalExisted = originalExisted; + OriginalContent = originalContent; + ErrorKind = errorKind; + } + + public bool HasChanges { get; } + + public string ProposedContent { get; } + + 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..a549c1c --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/Hooks/CodexHookTrustStatusParser.cs @@ -0,0 +1,119 @@ +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 matchingEntries = entries + .EnumerateArray() + .Where(entry => ReadHooks(entry).Any(hook => IsExpectedCommand(hook, expectedCommand))) + .ToArray(); + if (matchingEntries.Any(HasConfigurationErrors)) + { + return CodexHookTrustEvaluation.Unavailable; + } + + var matchingHooks = matchingEntries + .SelectMany(ReadHooks) + .Where(hook => IsExpectedCommand(hook, expectedCommand)) + .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; + } + + if (requiredHooks.Any(hook => !HasMatchAllMatcher(hook))) + { + 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; + } + + if (requiredHooks.Any(hook => !ReadBoolean(hook, "enabled"))) + { + return CodexHookTrustEvaluation.Unavailable; + } + + 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 bool IsExpectedCommand(JsonElement hook, string expectedCommand) => + ReadString(hook, "command") is { } command && + string.Equals(command, expectedCommand, StringComparison.Ordinal); + + private static bool HasConfigurationErrors(JsonElement entry) => + entry.TryGetProperty("errors", out var errors) && + errors.ValueKind == JsonValueKind.Array && + errors.GetArrayLength() > 0; + + private static bool HasMatchAllMatcher(JsonElement hook) + { + if (!hook.TryGetProperty("matcher", out var matcher) || + matcher.ValueKind == JsonValueKind.Null) + { + return true; + } + + return matcher.ValueKind == JsonValueKind.String && + matcher.GetString() is "" or "*"; + } + + private static string? ReadString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static bool ReadBoolean(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.True; +} + +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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +