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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
New-Item -ItemType Directory -Path 'package\scripts' -Force | Out-Null
New-Item -ItemType Directory -Path 'package\dist' -Force | Out-Null
Copy-Item -LiteralPath 'dist\WeeklyUsageIndicator.exe','dist\SHA256SUMS.txt' -Destination 'package\dist'
Copy-Item -LiteralPath 'scripts\install.ps1','scripts\uninstall.ps1' -Destination 'package\scripts'
Copy-Item -LiteralPath 'scripts\install.ps1','scripts\uninstall.ps1','scripts\install-environment.ps1' -Destination 'package\scripts'
Copy-Item -LiteralPath 'README.md','PRIVACY.md','SECURITY.md','LICENSE' -Destination 'package'
Compress-Archive -Path 'package\*' -DestinationPath "CodexWeeklyUsageIndicator-$env:GITHUB_REF_NAME-win-x64.zip"
- name: Publish release
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ This repository contains a small Windows-only WinForms utility. Keep changes foc
- Persist at most one credential-free Claude recovery snapshot containing only percentages, reset times, and update time. Use it only for transient cold-start failures, delete it after 24 hours, its Fable reset, or an authentication/schema failure, and never let it suppress the first live request in a new process.
- Stop the app-server child process when the widget pauses or exits.
- Preserve the single-instance mutex and the always-on-top tool-window behavior.
- The installer must launch the `--supervise` mode through the per-user interactive, least-privilege scheduled task, never directly from Codex. The supervisor retries nonzero widget exits/start failures only; normal Quit must end supervision. Do not substitute Task Scheduler RestartOnFailure for this loop: it did not retry an exited action in live tests. Disable/stop the task before upgrade or uninstall; filter processes by the current user's exact installed EXE path.
- Check the install directory's final handle path before stopping tasks or processes: packaged shells can redirect AppData even without reporting a package identity. Package `install-environment.ps1` with both lifecycle scripts.

## Validation

Expand All @@ -41,4 +43,5 @@ Then check that:
6. A temporary Claude command, network, or service error keeps the last successful value visible and reports the update delay in the tooltip.
7. A cold-start transient failure uses only a recent, unexpired sanitized snapshot and still attempts a live Claude request first.
8. The context menu works, the widget hides, and its app-server child exits when Codex closes.
9. `install.ps1` and `uninstall.ps1` only modify the current user's dedicated install directory and Startup shortcut.
9. `install.ps1` and `uninstall.ps1` only modify the current user's dedicated install directory, legacy Startup shortcut, and SID-named scheduled task.
10. The task owns the supervisor and its widget child independently of Codex. Forced termination of the widget child recovers after about one minute; normal Quit ends both processes and does not recover. Reinstall leaves one supervisor and one widget; uninstall removes the task before stopping the app.
1 change: 1 addition & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The application:
- does not read, print, log, copy, or persist Claude authentication tokens;
- does not collect account identifiers;
- does not include telemetry;
- registers a per-user Windows logon/recovery task containing the local executable path and Windows user SID, with no stored password or elevated privileges;
- makes no outbound request for Codex usage;
- makes no direct Claude network request; it invokes `claude.exe` in safe mode without a shell or persistent session, caches successful `/usage` results in memory for ten minutes, and deletes the local recovery snapshot after 24 hours, its Fable reset, or an authentication/schema failure.

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ An unofficial Windows widget that stays on top while Codex Desktop is running an
- Supports dragging, copying the current values, toggling always-on-top, and turning the Claude panel on or off from the right-click menu.
- Remembers the last dragged position and restores it on the next launch.
- Hides while another foreground app is fullscreen, then returns at the saved position.
- Starts a small background watcher at Windows sign-in so it can follow future Codex launches.
- Uses a per-user Windows scheduled task at sign-in, so the widget runs independently of Codex. A lightweight supervisor restarts the widget after an abnormal exit, waiting one minute (up to 999 retries per supervisor run).

The widget does not read or store login tokens, account details, or usage history. It stores only the latest successful Claude percentages, reset times, and update time for short-lived recovery. Claude Code itself owns authentication and token refresh. See [PRIVACY.md](PRIVACY.md).

Expand All @@ -36,15 +36,17 @@ The widget does not read or store login tokens, account details, or usage histor

1. Download and extract the Windows zip from [Releases](https://github.com/GiantForestStudio/codex-weekly-usage-indicator/releases).
2. Review the included PowerShell scripts.
3. Run:
3. Open a standalone Windows PowerShell window (outside packaged apps such as Codex), change to the extracted directory, and run:

```powershell
.\scripts\install.ps1
```

The app is installed to `%LOCALAPPDATA%\CodexWeeklyUsageIndicator` and a per-user Startup shortcut is created.
The app is installed to `%LOCALAPPDATA%\CodexWeeklyUsageIndicator`. A per-user `CodexWeeklyUsageIndicator-<Windows SID>` scheduled task starts its supervisor at sign-in, using the signed-in user's normal privileges without storing a password. The supervisor launches and watches the widget; two processes from the same EXE are expected, but only one window. Installation also starts the task immediately, checks that both processes appear, and then removes the old Startup shortcut. Windows Task Scheduler must be available; an installation error must be resolved before relying on automatic recovery.
The saved window position and Claude visibility preference are kept locally in `settings.json` inside that install directory. One sanitized Claude recovery snapshot may be kept in `claude-usage-cache.json` and is ignored after 24 hours or after its Fable reset.

Run installation and removal outside packaged app terminals: Windows can redirect their AppData writes into an app-private folder that Task Scheduler cannot see, causing `0x80070002` even when that terminal reports the EXE exists. Both scripts check the real directory path and refuse redirected locations before changing tasks or running widgets.

To uninstall:

```powershell
Expand All @@ -53,6 +55,8 @@ To uninstall:

Release binaries are currently unsigned, so Windows may display a warning. SHA-256 checksums are included with each release.

Choosing **종료** from the widget menu exits normally and also ends the supervisor. It starts again at your next Windows sign-in. To start it sooner with recovery enabled, run its `CodexWeeklyUsageIndicator-<Windows SID>` task in Windows Task Scheduler or run `scripts\install.ps1` again. Launching the EXE directly does not enable supervision for that process. Stopping the scheduled task or killing the supervisor also stops automatic recovery until the task is started again. Uninstall removes the scheduled task before deleting the app.

## Build from source

```powershell
Expand Down
47 changes: 47 additions & 0 deletions scripts/install-environment.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# AppData redirection can survive even when the shell reports no package identity.
# Check the actual directory handle before touching tasks, processes, or app data.
function Assert-WidgetInstallPath {
param([Parameter(Mandatory = $true)][string]$Path)

if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
if (-not ('WeeklyUsageIndicator.InstallPath' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace WeeklyUsageIndicator {
public static class InstallPath {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(string path, uint access,
uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandle(SafeFileHandle handle,
StringBuilder path, uint length, uint flags);
public static string Resolve(string path) {
using (var handle = CreateFile(path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
var resolved = new StringBuilder(32768);
var length = GetFinalPathNameByHandle(handle, resolved, (uint)resolved.Capacity, 0);
if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error());
if (length >= resolved.Capacity) throw new InvalidOperationException("Install path is too long.");
var value = resolved.ToString();
return value.StartsWith(@"\\?\") ? value.Substring(4) : value;
}
}
}
}
'@
}
# MSIX may merge a real directory with app-private files. Checking only the
# directory would miss those redirected existing files during upgrades.
$candidates = @($Path) + @(Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { $_.FullName })
foreach ($candidate in $candidates) {
$expected = [IO.Path]::GetFullPath($candidate).TrimEnd('\')
$actual = [WeeklyUsageIndicator.InstallPath]::Resolve($expected).TrimEnd('\')
if (-not $actual.Equals($expected, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Windows redirected the install folder. No tasks or running widgets were changed. Open Windows PowerShell from the Start menu (outside Codex or another packaged app), then run this script again.'
}
}
}
52 changes: 42 additions & 10 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
@@ -1,35 +1,67 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'install-environment.ps1')

$repositoryRoot = Split-Path -Parent $PSScriptRoot
$sourceExecutable = Join-Path $repositoryRoot 'dist\WeeklyUsageIndicator.exe'
$installDirectory = Join-Path $env:LOCALAPPDATA 'CodexWeeklyUsageIndicator'
$installedExecutable = Join-Path $installDirectory 'WeeklyUsageIndicator.exe'
$startupDirectory = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
$shortcutPath = Join-Path $startupDirectory 'Codex Weekly Usage Indicator.lnk'
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = "CodexWeeklyUsageIndicator-$userSid"

if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) {
throw "Build output not found: $sourceExecutable. Download a release or run .\scripts\build.ps1 first."
}

$runningIndicators = @(Get-Process -Name 'WeeklyUsageIndicator' -ErrorAction SilentlyContinue)
New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null
Assert-WidgetInstallPath -Path $installDirectory

# Stop scheduler recovery before replacing the binary. Never stop another user's copy.
$existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue
if ($existingTask) {
Disable-ScheduledTask -InputObject $existingTask | Out-Null
Stop-ScheduledTask -InputObject $existingTask
}

$runningIndicators = @(Get-Process -Name 'WeeklyUsageIndicator' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -eq $installedExecutable })
if ($runningIndicators.Count -gt 0) {
$runningIndicators | Stop-Process -Force
foreach ($runningIndicator in $runningIndicators) {
try { [void]$runningIndicator.WaitForExit(5000) } catch { }
}
}

New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null
Copy-Item -LiteralPath $sourceExecutable -Destination $installedExecutable -Force

$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $installedExecutable
$shortcut.WorkingDirectory = $installDirectory
$shortcut.Description = 'Codex and Claude usage indicator'
$shortcut.Save()
$action = New-ScheduledTaskAction -Execute $installedExecutable -Argument '--supervise' -WorkingDirectory $installDirectory
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userSid
$principal = New-ScheduledTaskPrincipal -UserId $userSid -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings `
-Description 'Starts the usage widget independently of Codex; retries abnormal exits after one minute. Normal Quit stays closed until the next logon or manual task start.' `
-Force | Out-Null

Start-Process -FilePath $installedExecutable -WorkingDirectory $installDirectory -WindowStyle Hidden
# Scheduler launches outside the installing app's process lifetime/job. Starting
# the EXE directly here could tie it to Codex again during an app update.
Start-ScheduledTask -TaskName $taskName -TaskPath '\'
$started = $false
for ($attempt = 0; $attempt -lt 20; $attempt++) {
Start-Sleep -Milliseconds 500
# The supervisor and its UI child must both be alive.
$started = @(Get-Process -Name 'WeeklyUsageIndicator' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -eq $installedExecutable }).Count -eq 2
if ($started) { break }
}
if (-not $started) {
throw "The scheduled task did not start the installed widget. Check '$taskName' in Windows Task Scheduler. If running inside a packaged app such as Codex, rerun from a standalone Windows PowerShell window to avoid AppData redirection."
}
if (Test-Path -LiteralPath $shortcutPath -PathType Leaf) {
Remove-Item -LiteralPath $shortcutPath -Force
}

Write-Host "Installed: $installedExecutable"
Write-Host "Startup shortcut: $shortcutPath"
Write-Host "Logon and recovery task: $taskName"
15 changes: 14 additions & 1 deletion scripts/uninstall.ps1
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'install-environment.ps1')

$installDirectory = Join-Path $env:LOCALAPPDATA 'CodexWeeklyUsageIndicator'
$startupDirectory = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
$shortcutPath = Join-Path $startupDirectory 'Codex Weekly Usage Indicator.lnk'
$installedExecutable = Join-Path $installDirectory 'WeeklyUsageIndicator.exe'
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$taskName = "CodexWeeklyUsageIndicator-$userSid"
Assert-WidgetInstallPath -Path $installDirectory

$runningIndicators = @(Get-Process -Name 'WeeklyUsageIndicator' -ErrorAction SilentlyContinue)
# Remove recovery before stopping the app or deleting its files.
$existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue
if ($existingTask) {
Disable-ScheduledTask -InputObject $existingTask | Out-Null
Stop-ScheduledTask -InputObject $existingTask
Unregister-ScheduledTask -TaskName $taskName -TaskPath '\' -Confirm:$false
}
$runningIndicators = @(Get-Process -Name 'WeeklyUsageIndicator' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -eq $installedExecutable })
if ($runningIndicators.Count -gt 0) {
$runningIndicators | Stop-Process -Force
foreach ($runningIndicator in $runningIndicators) {
Expand Down
8 changes: 6 additions & 2 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ namespace WeeklyUsageIndicator;
internal static class Program
{
[STAThread]
private static void Main(string[] args)
private static int Main(string[] args)
{
if (args.Any(argument => argument.Equals("--supervise", StringComparison.OrdinalIgnoreCase)))
return WidgetSupervisor.Run();

using var singleInstance = new Mutex(
initiallyOwned: true,
name: @"Local\CodexWeeklyUsageIndicator",
createdNew: out var isFirstInstance);
if (!isFirstInstance) return;
if (!isFirstInstance) return 0;

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 Distinguish duplicate launches from normal Quit

When a widget is already running without its supervisor—for example, after the supervisor is killed and the scheduled task is started again—the new supervisor's child reaches this branch. Returning 0 makes RunLoop interpret the mutex collision as a normal menu Quit and terminate the supervisor, so restarting the task does not restore recovery and a later widget crash remains unrecovered. Return a distinct retryable status for this case or have the supervisor wait for the existing widget.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.


var previewMode = args.Any(argument =>
argument.Equals("--preview", StringComparison.OrdinalIgnoreCase));

ApplicationConfiguration.Initialize();
Application.Run(new UsageIndicatorForm(previewMode));
GC.KeepAlive(singleInstance);
return 0;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/WeeklyUsageIndicator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<AssemblyName>WeeklyUsageIndicator</AssemblyName>
<RootNamespace>WeeklyUsageIndicator</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>1.3.3</Version>
<Version>1.3.4</Version>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
Expand Down
48 changes: 48 additions & 0 deletions src/WidgetSupervisor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.ComponentModel;
using System.Diagnostics;

namespace WeeklyUsageIndicator;

internal static class WidgetSupervisor
{
internal static readonly TimeSpan RetryInterval = TimeSpan.FromMinutes(1);
internal const int MaximumRestarts = 999;

internal static int Run()
{
using var singleSupervisor = new Mutex(true,
@"Local\CodexWeeklyUsageIndicator.Supervisor", out var isFirstInstance);
if (!isFirstInstance) return 0;

var executable = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot locate the widget executable.");
var result = RunLoop(() =>
{
using var child = Process.Start(new ProcessStartInfo(executable)
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = AppContext.BaseDirectory
}) ?? throw new Win32Exception("Cannot start the widget.");
child.WaitForExit();
return child.ExitCode;
}, Thread.Sleep);
GC.KeepAlive(singleSupervisor);
return result;
}

// A normal menu Quit returns zero and ends supervision. Do not depend on
// Task Scheduler RestartOnFailure: it did not retry an exited action in UAT.
internal static int RunLoop(Func<int> runWidget, Action<TimeSpan> wait)
{
for (var restarts = 0; ; restarts++)
{
int exitCode;
try { exitCode = runWidget(); }
catch (Win32Exception) { exitCode = 1; }

if (exitCode == 0 || restarts >= MaximumRestarts) return exitCode;
wait(RetryInterval);
}
}
}
24 changes: 24 additions & 0 deletions tests/WeeklyUsageIndicator.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

var tests = new (string Name, Func<Task> Run)[]
{
("supervisor retries abnormal exits but respects normal Quit", TestSupervisorAsync),
("official Claude /usage output is parsed", TestObservedUsageOutputAsync),
("usage without reset times remains valid through client and tooltip", TestUsageWithoutResetsAsync),
("optional limits fail independently", TestIndependentLimitsAsync),
Expand Down Expand Up @@ -31,6 +32,29 @@

return;

static Task TestSupervisorAsync()
{
var runs = 0;
var waits = 0;
var result = WidgetSupervisor.RunLoop(() => ++runs < 3 ? -1 : 0, interval =>
{
Assert(interval == TimeSpan.FromMinutes(1), "recovery must not tight-loop");
waits++;
});
Assert(result == 0 && runs == 3 && waits == 2, "nonzero exits retry; normal Quit stops");
runs = waits = 0;
result = WidgetSupervisor.RunLoop(() => { runs++; return 0; }, _ => waits++);
Assert(result == 0 && runs == 1 && waits == 0, "normal Quit must never relaunch");
runs = waits = 0;
result = WidgetSupervisor.RunLoop(() =>
{
runs++;
throw new System.ComponentModel.Win32Exception("test start failure");
}, _ => waits++);
Assert(result != 0 && runs == 1000 && waits == 999, "failed starts have a bounded retry budget");
return Task.CompletedTask;
}

static Task TestObservedUsageOutputAsync()
{
var now = new DateTimeOffset(2026, 9, 1, 4, 25, 0, TimeSpan.Zero);
Expand Down