Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions docs/ACTIVITY_DOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ state in memory.
- A completion animation after the final active turn finishes

The widget accepts only the lifecycle event type and the session and turn identifiers
provided by Codex. It does not collect prompts, responses, transcript contents, transcript
paths, or model output.
provided by Codex hooks. While activity is present, it also checks turn completion metadata
through the official local app-server protocol, with turn items omitted. It does not collect
prompts, responses, transcript contents, transcript paths, or model output.

## Set up in the widget

Expand Down Expand Up @@ -73,10 +74,21 @@ Each session owns at most one active turn. A later `UserPromptSubmit` replaces a
turn in that session, and a late `Stop` for the old turn cannot clear the new one. Duplicate
events are harmless. `SessionEnd` removes only the matching session.

If Codex terminates without sending a final lifecycle event, a later turn in the same session
replaces the stale turn. Restarting the widget also clears all in-memory activity state. The
widget does not use an arbitrary timeout because legitimate Codex tasks can run for a long
time.
While activity is present, the widget checks every 15 seconds whether each tracked turn has
finished. It uses `thread/turns/list` with `itemsView: "notLoaded"`, follows pagination, and
clears a turn only when its exact identifier has a terminal status and an explicit completion
timestamp. A late result cannot clear a newer turn in the same session. No checks are sent
while idle, and each turn's check has a five-second request timeout.

This requires a Codex CLI that supports `thread/turns/list` and completion timestamps, verified
with CLI 0.154.0. Unsupported requests, unavailable history, missing turns, and failed checks
leave hook activity unchanged. In particular, a separate app-server can reconstruct a running
turn as `interrupted` without a completion timestamp; that is not evidence of completion.

If Codex terminates without recording completion or sending a final lifecycle event, a later
turn in the same session replaces the stale turn. Restarting the widget also clears all
in-memory activity state. The widget never expires activity solely because a task has run
for a long time.

If the widget is closed, the hook bridge exits successfully after a short connection attempt
and Codex continues normally.
8 changes: 6 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence
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 private transcript/database polling.
A later turn in the same session recovers missing cleanup; a hard Codex termination with no
later lifecycle event is cleared by restarting the widget.
While active, the monitor checks tracked turns every 15 seconds through
`CodexTurnCompletionReader`, using official `thread/turns/list` metadata with items omitted.
Only an exact turn match with a terminal status and completion timestamp clears activity;
missing or unavailable evidence preserves it. Each check is bounded to five seconds and
shutdown cancels pending checks before disposing the shared app-server session. A hard
termination with no recorded completion still requires a later lifecycle event or restart.
- Unhandled exceptions and CLI diagnostics are recorded locally for support.
- Publish trimming is disabled because WPF is not a safe trimming boundary.

Expand Down
4 changes: 3 additions & 1 deletion src/CodexUsageWidget/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ protected override void OnStartup(StartupEventArgs e)
usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message);
var resetUseCase = new RateLimitResetUseCase(resetConsumer, usageMonitor);

activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource());
activityMonitor = new CodexActivityMonitor(
new CodexActivityPipeSignalSource(),
new CodexTurnCompletionReader(appServerSession));
activityMonitor.DiagnosticMessage += (_, message) => _logger.Info(message);
var processPath = Environment.ProcessPath ??
throw new InvalidOperationException("Cannot determine the widget executable path.");
Expand Down
121 changes: 120 additions & 1 deletion src/CodexUsageWidget/Application/CodexActivityMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,92 @@ public sealed class CodexActivityMonitor : IAsyncDisposable
private readonly object _stateLock = new();
private readonly object _transitionLock = new();
private readonly ICodexActivitySignalSource _source;
private readonly ICodexTurnCompletionReader? _completionReader;
private readonly TimeSpan _reconciliationInterval;
private readonly TimeSpan _requestTimeout;
private readonly CancellationTokenSource _lifetime = new();
private readonly SemaphoreSlim _reconciliationGate = new(1, 1);
private Task? _reconciliationTask;
private int _disposed;
private bool _completionCheckFailed;
private readonly Dictionary<string, string> _activeTurnsBySession =
new(StringComparer.Ordinal);
private bool _started;

public CodexActivityMonitor(ICodexActivitySignalSource source)
public CodexActivityMonitor(
ICodexActivitySignalSource source,
ICodexTurnCompletionReader? completionReader = null,
TimeSpan? reconciliationInterval = null,
TimeSpan? requestTimeout = null)
{
_source = source;
_completionReader = completionReader;
_reconciliationInterval = reconciliationInterval ?? TimeSpan.FromSeconds(15);
_requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(5);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(_reconciliationInterval, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(_requestTimeout, TimeSpan.Zero);
}

public async Task ReconcileAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed != 0, this);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the timer before marking the monitor disposed

If the periodic timer fires after DisposeAsync sets _disposed but before _lifetime.CancelAsync() requests cancellation, this check throws ObjectDisposedException while the exception filter in RunReconciliationAsync still sees an uncancelled token. That permanently faults _reconciliationTask, and the subsequent await in DisposeAsync propagates through MainWindowOnClosing, preventing the usage monitor and shared app-server session from being disposed during shutdown.

Useful? React with 👍 / 👎.

if (_completionReader is null ||
!await _reconciliationGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}

try
{
await ReconcileWithGateHeldAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_reconciliationGate.Release();
}
}

private async Task ReconcileWithGateHeldAsync(CancellationToken cancellationToken)
{
KeyValuePair<string, string>[] turns;
lock (_stateLock)
{
turns = _activeTurnsBySession.ToArray();
}

var checkFailed = false;
foreach (var turn in turns)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _lifetime.Token);
timeout.CancelAfter(_requestTimeout);
if (await _completionReader!.IsCompletedAsync(turn.Key, turn.Value, timeout.Token)
.ConfigureAwait(false))
{
timeout.Token.ThrowIfCancellationRequested();
SourceOnSignalReceived(new(CodexActivitySignalKind.TurnStopped, turn.Key, turn.Value));
}
}
catch (OperationCanceledException) when (
cancellationToken.IsCancellationRequested || _lifetime.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
checkFailed = true;
}
}

if (checkFailed && !_completionCheckFailed)
{
DiagnosticMessage?.Invoke(this,
"Codex activity completion check unavailable; keeping hook activity until completion is confirmed.");
}

_completionCheckFailed = checkFailed;
}

public event Action<bool>? ActivityChanged;
Expand All @@ -31,6 +110,7 @@ public bool IsActive

public async Task StartAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed != 0, this);
if (_started)
{
return;
Expand All @@ -39,6 +119,30 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
_started = true;
_source.SignalReceived += SourceOnSignalReceived;
await _source.StartAsync(cancellationToken).ConfigureAwait(false);
if (_completionReader is not null)
{
_reconciliationTask = RunReconciliationAsync(_lifetime.Token);
}
}

private async Task RunReconciliationAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(_reconciliationInterval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await ReconcileAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
// Disposal marks the monitor before cancelling the timer. A tick in that
// interval is normal shutdown, even if cancellation is not visible yet.
catch (ObjectDisposedException) when (Volatile.Read(ref _disposed) != 0)
{
}
}

private void SourceOnSignalReceived(CodexActivitySignal signal)
Expand Down Expand Up @@ -95,7 +199,22 @@ private void SourceOnSignalReceived(CodexActivitySignal signal)

public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}

_source.SignalReceived -= SourceOnSignalReceived;
await _lifetime.CancelAsync().ConfigureAwait(false);
if (_reconciliationTask is not null)
{
await _reconciliationTask.ConfigureAwait(false);
}

await _reconciliationGate.WaitAsync().ConfigureAwait(false);
_reconciliationGate.Release();
await _source.DisposeAsync().ConfigureAwait(false);
_lifetime.Dispose();
_reconciliationGate.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CodexUsageWidget.Application;

public interface ICodexTurnCompletionReader
{
Task<bool> IsCompletedAsync(string sessionId, string turnId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ await connection.RequestAsync(
},
capabilities = new
{
experimentalApi = true,
optOutNotificationMethods = Array.Empty<string>()
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Text.Json;
using CodexUsageWidget.Application;

namespace CodexUsageWidget.Infrastructure.Codex;

public sealed class CodexTurnCompletionReader(ICodexAppServerSession session) : ICodexTurnCompletionReader
{
public async Task<bool> IsCompletedAsync(
string sessionId, string turnId, CancellationToken cancellationToken)
{
string? cursor = null;
var seenCursors = new HashSet<string>(StringComparer.Ordinal);
do
{
cancellationToken.ThrowIfCancellationRequested();
var result = await session.RequestAsync(
"thread/turns/list",
new { threadId = sessionId, cursor, limit = 100, sortDirection = "desc", itemsView = "notLoaded" },
cancellationToken).ConfigureAwait(false);
if (result.ValueKind != JsonValueKind.Object ||
!result.TryGetProperty("data", out var turns) || turns.ValueKind != JsonValueKind.Array)
{
return false;
}

foreach (var turn in turns.EnumerateArray())
{
if (turn.ValueKind == JsonValueKind.Object &&
turn.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String &&
string.Equals(id.GetString(), turnId, StringComparison.Ordinal))
{
// A foreign running turn can be reconstructed as interrupted without an end time.
// Never infer completion from that status alone, or from an absent turn.
return turn.TryGetProperty("status", out var status) &&
status.ValueKind == JsonValueKind.String &&
status.GetString() is "completed" or "interrupted" or "failed" &&
turn.TryGetProperty("completedAt", out var completedAt) &&
completedAt.ValueKind == JsonValueKind.Number &&
completedAt.TryGetInt64(out var timestamp) && timestamp > 0;
}
}

cursor = result.TryGetProperty("nextCursor", out var next) && next.ValueKind == JsonValueKind.String
? next.GetString()
: null;
}
while (!string.IsNullOrEmpty(cursor) && seenCursors.Add(cursor));

return false;
}
}
Loading