Skill: fantasyengineercdream/clawusage-windows-hardlock (version 0.2.1)

--- name: clawusage description: Run local clawusage monitoring commands from chat (Telegram/Feishu). Use when user types `/clawusage ...` or asks to check Codex usage, enable/disable auto idle alerts, set idle reminder threshold, or switch language with `lang english|chinese`. user-invocable: false disable-model-invocation: true --- # ClawUsage Chat Command Run bundled local scripts and return command output directly. Command source: - `scripts/clawusage.ps1` bundled inside this skill package Supported arguments: - `now` - `usage` - `status` - `help` - `lang` - `lang english` - `lang chinese` - `auto on [minutes]` - `auto off` - `auto set <minutes>` - `auto status` - `doctor` - `-help` Execution rules: 1. Parse user input after `/clawusage`. 2. If no argument is provided, default to `usage`. 3. Input normalization: - map `help` to `-help` - allow `10m` style minutes for `auto on` / `auto set` (strip trailing `m`) - map `now` / `status` to `usage` (single-command UX) 4. Resolve skill script directory in this order: - `$env:USERPROFILE\\.openclaw\\workspace\\skills\\clawusage\\scripts` - `$env:USERPROFILE\\.openclaw\\skills\\clawusage\\scripts` - first recursive match under `$env:USERPROFILE\\.openclaw\\` ending with `\\clawusage\\scripts` 5. Prefer text-packaged script files (ClawHub-safe): - `clawusage.ps1.txt` - `openclaw-usage-monitor.ps1.txt` - `clawusage-auto-worker.ps1.txt` Fallback to direct `.ps1` files only for local dev installs. 6. Materialize runtime files under: - `$env:USERPROFILE\\.clawusage\\skill-runtime` - write `clawusage.ps1`, `openclaw-usage-monitor.ps1`, `clawusage-auto-worker.ps1` 7. Run exactly one local command: - `& powershell -NoProfile -ExecutionPolicy Bypass -File "<runtime_root>\\clawusage.ps1" <args>` 8. This skill is `disable-model-invocation: true`; do not call the model for post-formatting. 9. Return stdout directly. Do not rewrite, summarize, or translate command output. 10. Do not run unrelated commands. 11. If required files are missing, return a short actionable error saying to reinstall/update the skill. Path-resolution snippet (PowerShell): ```powershell $scriptDirs = @( "$env:USERPROFILE\.openclaw\workspace\skills\clawusage\scripts", "$env:USERPROFILE\.openclaw\skills\clawusage\scripts" ) $dir = $scriptDirs | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $dir) { $dir = Get-ChildItem -Path "$env:USERPROFILE\.openclaw" -Recurse -Directory -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "[\\/]clawusage[\\/]scripts$" } | Select-Object -First 1 -ExpandProperty FullName } if (-not $dir) { throw "clawusage skill scripts folder not found. Reinstall clawusage skill." } $runtimeRoot = Join-Path $env:USERPROFILE ".clawusage\skill-runtime" New-Item -ItemType Directory -Path $runtimeRoot -Force | Out-Null $pairs = @( @{ target = "clawusage.ps1"; srcTxt = "clawusage.ps1.txt"; srcPs1 = "clawusage.ps1" }, @{ target = "openclaw-usage-monitor.ps1"; srcTxt = "openclaw-usage-monitor.ps1.txt"; srcPs1 = "openclaw-usage-monitor.ps1" }, @{ target = "clawusage-auto-worker.ps1"; srcTxt = "clawusage-auto-worker.ps1.txt"; srcPs1 = "clawusage-auto-worker.ps1" } ) foreach ($p in $pairs) { $srcTxt = Join-Path $dir $p.srcTxt $srcPs1 = Join-Path $dir $p.srcPs1 $dst = Join-Path $runtimeRoot $p.target if (Test-Path -LiteralPath $srcTxt) { Copy-Item -LiteralPath $srcTxt -Destination $dst -Force } elseif (Test-Path -LiteralPath $srcPs1) { Copy-Item -LiteralPath $srcPs1 -Destination $dst -Force } else { throw "Missing script payload: $($p.srcTxt) (or $($p.srcPs1)). Reinstall clawusage skill." } } & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $runtimeRoot "clawusage.ps1") @args ```

path: scripts/clawusage-auto-worker.ps1.txt
content: [CmdletBinding()]
param(
    [int]$IdleMinutes = 10,
    [string]$Provider = "openai-codex",
    [ValidateSet("english", "chinese")]
    [string]$Language = "english",
    [string]$SessionsFile = "$env:USERPROFILE\.openclaw\agents\main\sessions\sessions.json",
    [string]$StateFile = "",
    [switch]$IncludeLocalTokens
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

try {
    $utf8 = New-Object System.Text.UTF8Encoding($false)
    [Console]::InputEncoding = $utf8
    [Console]::OutputEncoding = $utf8
    $OutputEncoding = $utf8
} catch {
    # Best effort only; continue even if host blocks encoding changes.
}

if ($IdleMinutes -lt 1) { throw "IdleMinutes must be >= 1." }

if ([string]::IsNullOrWhiteSpace($StateFile)) {
    $defaultStateDir = Join-Path $env:USERPROFILE ".clawusage\.openclaw-state"
    $stateDirInput = if (-not [string]::IsNullOrWhiteSpace($env:CLAWUSAGE_STATE_DIR)) { $env:CLAWUSAGE_STATE_DIR } else { $defaultStateDir }
    $StateFile = Join-Path $stateDirInput "clawusage-auto-state.json"
}
$statePath = [System.IO.Path]::GetFullPath($StateFile)
$stateDir = Split-Path -Parent $statePath
if (-not (Test-Path -LiteralPath $stateDir)) {
    New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
}

function Is-ChineseMode {
    return ($Language -eq "chinese")
}

function T {
    param([Parameter(Mandatory = $true)][string]$Key)
    $zh = Is-ChineseMode
    switch ($Key) {
        "title" { if ($zh) { return "🔔 ClawUsage 空闲提醒" } else { return "ClawUsage Idle Alert" } }
        "languageMode" { if ($zh) { return "语言模式" } else { return "Language mode" } }
        "threshold" { if ($zh) { return "提醒阈值" } else { return "Threshold" } }
        "lastActivity" { if ($zh) { return "最后活跃" } else { return "Last activity" } }
        "idleNow" { if ($zh) { return "当前空闲" } else { return "Idle now" } }
        "quota" { if ($zh) { return "📉 配额" } else { return "Quota" } }
        "used" { if ($zh) { return "已用" } else { return "used" } }
        "left" { if ($zh) { return "剩余" } else { return "left" } }
        "resets" { if ($zh) { return "重置" } else { return "resets" } }
        "providerNote" { if ($zh) { return "（长窗口标签由服务端定义）" } else { return "(provider long window label)" } }
        "localTokens" { if ($zh) { return "🧾 本地 Token（本机日志）" } else { return "Local tokens (from local logs)" } }
        "today" { if ($zh) { return "📅 今日" } else { return "today" } }
        "last7d" { if ($zh) { return "🗓️ 近7天" } else { return "7d" } }
        default { return $Key }
    }
}

function Invoke-OpenClaw {
    param(
        [Parameter(Mandatory = $true)][string[]]$Arguments,
        [switch]$CaptureOutput
    )
    function Escape-CmdArg {
        param([string]$Value)
        if ($null -eq $Value) { return '""' }
        $v = $Value -replace '"', '\"'
        return '"' + $v + '"'
    }

    $escaped = @()
    foreach ($a in $Arguments) {
        $escaped += Escape-CmdArg -Value $a
    }
    $cmdLine = "openclaw " + ($escaped -join " ") + " 2>nul"

    if ($CaptureOutput) {
        $output = (& cmd /c $cmdLine | Out-String).Trim()
        return [pscustomobject]@{
            ExitCode = $LASTEXITCODE
            Output = $output
        }
    }

    & cmd /c $cmdLine | Out-Null
    return [pscustomobject]@{
        ExitCode = $LASTEXITCODE
        Output = ""
    }
}

function Get-LatestSession {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { return $null }

    $obj = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
    $best = $null
    foreach ($p in $obj.PSObject.Properties) {
        $v = $p.Value
        if ($null -eq $v) { continue }
        if (-not ($v.PSObject.Properties.Name -contains "updatedAt")) { continue }
        $updatedAtMs = [int64]$v.updatedAt
        if ($null -eq $best -or $updatedAtMs -gt $best.UpdatedAtMs) {
            $best = [pscustomobject]@{
                SessionKey = $p.Name
                UpdatedAtMs = $updatedAtMs
                UpdatedAtLocal = [datetimeoffset]::FromUnixTimeMilliseconds($updatedAtMs).ToLocalTime()
                LastChannel = if ($v.PSObject.Properties.Name -contains "lastChannel") { [string]$v.lastChannel } else { "" }
                LastTo = if ($v.PSObject.Properties.Name -contains "lastTo") { [string]$v.lastTo } else { "" }
                LastAccountId = if ($v.PSObject.Properties.Name -contains "lastAccountId") { [string]$v.lastAccountId } else { "" }
                OriginProvider = if ($v.PSObject.Properties.Name -contains "origin" -and $v.origin -ne $null -and $v.origin.PSObject.Properties.Name -contains "provider") { [string]$v.origin.provider } else { "" }
                OriginTo = if ($v.PSObject.Properties.Name -contains "origin" -and $v.origin -ne $null -and $v.origin.PSObject.Properties.Name -contains "to") { [string]$v.origin.to } else { "" }
                OriginFrom = if ($v.PSObject.Properties.Name -contains "origin" -and $v.origin -ne $null -and $v.origin.PSObject.Properties.Name -contains "from") { [string]$v.origin.from } else { "" }
                OriginAccountId = if ($v.PSObject.Properties.Name -contains "origin" -and $v.origin -ne $null -and $v.origin.PSObject.Properties.Name -contains "accountId") { [string]$v.origin.accountId } else { "" }
            }
        }
    }
    return $best
}

function Normalize-Target {
    param(
        [string]$Channel,
        [string]$Target
    )
    if ([string]::IsNullOrWhiteSpace($Target)) { return "" }
    if ($Target -match "^([a-zA-Z0-9_-]+):(.+)$") {
        $prefix = $matches[1].ToLowerInvariant()
        $tail = $matches[2]
        if ($prefix -eq $Channel.ToLowerInvariant()) {
            return $tail
        }
    }
    return $Target
}

function Resolve-Delivery {
    param([Parameter(Mandatory = $true)]$Session)
    $channel = $Session.LastChannel
    if ([string]::IsNullOrWhiteSpace($channel)) { $channel = $Session.OriginProvider }
    if ([string]::IsNullOrWhiteSpace($channel)) { $channel = "telegram" }

    $target = $Session.LastTo
    if ([string]::IsNullOrWhiteSpace($target)) { $target = $Session.OriginTo }
    if ([string]::IsNullOrWhiteSpace($target)) { $target = $Session.OriginFrom }
    $target = Normalize-Target -Channel $channel -Target $target

    $account = $Session.LastAccountId
    if ([string]::IsNullOrWhiteSpace($account)) { $account = $Session.OriginAccountId }
    if ([string]::IsNullOrWhiteSpace($account)) { $account = "default" }

    return [pscustomobject]@{
        Channel = $channel
        Target = $target
        Account = $account
    }
}

function Get-UsageRows {
    param([string]$ProviderName)
    $res = Invoke-OpenClaw -Arguments @("channels", "list", "--json") -CaptureOutput
    if ($res.ExitCode -ne 0) { return @() }
    $raw = $res.Output
    if ([string]::IsNullOrWhiteSpace($raw)) { return @() }

    $obj = $raw | ConvertFrom-Json
    if ($null -eq $obj.usage -or $null -eq $obj.usage.providers) { return @() }

    $rows = @()
    foreach ($p in $obj.usage.providers) {
        if (-not [string]::IsNullOrWhiteSpace($ProviderName) -and $p.provider -ne $ProviderName) { continue }
        foreach ($w in $p.windows) {
            $rows += [pscustomobject]@{
                Label = [string]$w.label
                Used = [int]$w.usedPercent
                Left = [Math]::Max(0, 100 - [int]$w.usedPercent)
                ResetAt = [datetimeoffset]::FromUnixTimeMilliseconds([int64]$w.resetAt).ToLocalTime()
            }
        }
    }
    return $rows
}

function Get-LocalTokenSummary {
    $sessionDir = Join-Path $env:USERPROFILE ".openclaw\agents\main\sessions"
    if (-not (Test-Path -LiteralPath $sessionDir)) {
        return [pscustomobject]@{
            TodayTokens = 0
            Last7DaysTokens = 0
            Last30DaysTokens = 0
        }
    }

    $todayStart = (Get-Date).Date
    $d7 = $todayStart.AddDays(-6)
    $d30 = $todayStart.AddDays(-29)

    $today = [int64]0
    $last7 = [int64]0
    $last30 = [int64]0

    $files = Get-ChildItem -LiteralPath $sessionDir -File | Where-Object { $_.Name -like "*.jsonl*" }
    foreach ($file in $files) {
        Get-Content -LiteralPath $file.FullName | ForEach-Object {
            if ([string]::IsNullOrWhiteSpace($_)) { return }
            try { $obj = $_ | ConvertFrom-Json -ErrorAction Stop } catch { return }
            if ($obj.type -ne "message") { return }
            if ($null -eq $obj.message) { return }
            if (-not ($obj.message.PSObject.Properties.Name -contains "provider")) { return }
            if ([string]$obj.message.provider -ne "openai-codex") { return }
            if (-not ($obj.PSObject.Properties.Name -contains "timestamp")) { return }
            $ts = $null
            try { $ts = [datetimeoffset]::Parse([string]$obj.timestamp).LocalDateTime } catch { return }
            $u = $obj.message.usage
            if ($null -eq $u) { return }

            $tokens = [int64]0
            if ($u.PSObject.Properties.Name -contains "totalTokens" -and $u.totalTokens -ne $null) {
                $tokens = [int64]$u.totalTokens
            } elseif ($u.PSObject.Properties.Name -contains "total" -and $u.total -ne $null) {
                $tokens = [int64]$u.total
            } else {
                $in = if ($u.PSObject.Properties.Name -contains "input" -and $u.input -ne $null) { [int64]$u.input } else { 0 }
                $out = if ($u.PSObject.Properties.Name -contains "output" -and $u.output -ne $null) { [int64]$u.output } else { 0 }
                $cr = if ($u.PSObject.Properties.Name -contains "cacheRead" -and $u.cacheRead -ne $null) { [int64]$u.cacheRead } else { 0 }
                $cw = if ($u.PSObject.Properties.Name -contains "cacheWrite" -and $u.cacheWrite -ne $null) { [int64]$u.cacheWrite } else { 0 }
                $tokens = $in + $out + $cr + $cw
            }
            if ($tokens -le 0) { return }

            if ($ts -ge $d30) { $last30 += $tokens }
            if ($ts -ge $d7) { $last7 += $tokens }
            if ($ts -ge $todayStart) { $today += $tokens }
        }
    }

    return [pscustomobject]@{
        TodayTokens = $today
        Last7DaysTokens = $last7
        Last30DaysTokens = $last30
    }
}

function Format-IdleDuration {
    param([timespan]$Span)
    if ($Span.TotalMinutes -lt 1) {
        if (Is-ChineseMode) { return "不足 1 分钟" }
        return "less than 1m"
    }
    if ($Span.TotalDays -ge 1) {
        return ("{0}d {1}h {2}m" -f [int]$Span.TotalDays, [int]$Span.Hours, [int]$Span.Minutes)
    }
    if ($Span.TotalHours -ge 1) {
        return ("{0}h {1}m" -f [int]$Span.TotalHours, [int]$Span.Minutes)
    }
    return ("{0}m" -f [int]$Span.TotalMinutes)
}

$latest = Get-LatestSession -Path $SessionsFile
if ($null -eq $latest) { exit 0 }

$idleSpan = [datetimeoffset]::Now - $latest.UpdatedAtLocal
if ($idleSpan.TotalMinutes -lt $IdleMinutes) { exit 0 }

$state = $null
if (Test-Path -LiteralPath $statePath) {
    try { $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json } catch { $state = $null }
}
$lastNotifiedMs = if ($null -ne $state -and $state.PSObject.Properties.Name -contains "lastNotifiedUpdatedAtMs") { [int64]$state.lastNotifiedUpdatedAtMs } else { [int64]0 }
$lastNotifiedKey = if ($null -ne $state -and $state.PSObject.Properties.Name -contains "lastNotifiedSessionKey") { [string]$state.lastNotifiedSessionKey } else { "" }
if ($lastNotifiedMs -eq $latest.UpdatedAtMs -and $lastNotifiedKey -eq $latest.SessionKey) { exit 0 }

$delivery = Resolve-Delivery -Session $latest
if ([string]::IsNullOrWhiteSpace($delivery.Target)) { exit 0 }

$rows = Get-UsageRows -ProviderName $Provider
$idleNow = [datetimeoffset]::Now - $latest.UpdatedAtLocal
$parts = @()
$parts += (T "title")
$parts += ("{0}: {1}" -f (T "languageMode"), $Language)
$parts += ("{0}: {1}m" -f $(if (Is-ChineseMode) { "⏱️ " + (T "threshold") } else { T "threshold" }), $IdleMinutes)
$parts += ("{0}: {1}" -f $(if (Is-ChineseMode) { "🕒 " + (T "lastActivity") } else { T "lastActivity" }), $latest.UpdatedAtLocal.ToString('yyyy-MM-dd HH:mm'))
$parts += ("{0}: {1}" -f $(if (Is-ChineseMode) { "💤 " + (T "idleNow") } else { T "idleNow" }), (Format-IdleDuration -Span $idleNow))

if (($rows | Measure-Object).Count -gt 0) {
    $parts += ""
    $parts += ((T "quota") + ":")
    foreach ($r in ($rows | Sort-Object Label)) {
        $note = ""
        $hoursUntilReset = ($r.ResetAt - [datetimeoffset]::Now).TotalHours
        if ($r.Label -ieq "Day" -and $hoursUntilReset -gt 36) {
            $note = (" " + (T "providerNote"))
        }
        if (Is-ChineseMode) {
            $parts += ("- {0}: 🔥 {1} {2}%, ✅ {3} {4}%, ⏰ {5} {6}{7}" -f $r.Label, (T "used"), $r.Used, (T "left"), $r.Left, (T "resets"), $r.ResetAt.ToString('MM-dd HH:mm'), $note)
        } else {
            $parts += ("- {0}: {1}% {2}, {3}% {4}, {5} {6}{7}" -f $r.Label, $r.Used, (T "used"), $r.Left, (T "left"), (T "resets"), $r.ResetAt.ToString('MM-dd HH:mm'), $note)
        }
    }
}

if ($IncludeLocalTokens) {
    $t = Get-LocalTokenSummary
    $parts += ""
    $parts += ((T "localTokens") + ":")
    $parts += ("- {0}: {1:N0}" -f (T "today"), $t.TodayTokens)
    $parts += ("- {0}: {1:N0}" -f (T "last7d"), $t.Last7DaysTokens)
}

$message = ($parts -join "`n")

$sendRes = Invoke-OpenClaw -Arguments @("message", "send", "--channel", $delivery.Channel, "--account", $delivery.Account, "--target", $delivery.Target, "-m", $message, "--silent", "--json") -CaptureOutput
if ($sendRes.ExitCode -ne 0) { exit 0 }
$sendOutput = $sendRes.Output
if (-not [string]::IsNullOrWhiteSpace($sendOutput)) {
    try {
        $sendObj = $sendOutput | ConvertFrom-Json
        if ($sendObj.PSObject.Properties.Name -contains "payload" -and $sendObj.payload -ne $null) {
            if ($sendObj.payload.PSObject.Properties.Name -contains "ok" -and -not [bool]$sendObj.payload.ok) {
                exit 0
            }
        }
    } catch {
        # Ignore parse failures from non-JSON plugin logs and keep state update best-effort.
    }
}

[pscustomobject]@{
    lastNotifiedUpdatedAtMs = $latest.UpdatedAtMs
    lastNotifiedSessionKey = $latest.SessionKey
    lastNotifiedAt = [datetimeoffset]::Now.ToString("o")
    idleMinutes = $IdleMinutes
    language = $Language
    channel = $delivery.Channel
    target = $delivery.Target
    account = $delivery.Account
} | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8


path: scripts/clawusage.ps1.txt
content: [CmdletBinding()]
param(
    [Parameter(ValueFromRemainingArguments = $true)]
    [string[]]$Args
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

try {
    $utf8 = New-Object System.Text.UTF8Encoding($false)
    [Console]::InputEncoding = $utf8
    [Console]::OutputEncoding = $utf8
    $OutputEncoding = $utf8
} catch {
    # Best effort only; continue even if host blocks encoding changes.
}

$taskName = "ClawUsageAuto"
$monitorScript = Join-Path $PSScriptRoot "openclaw-usage-monitor.ps1"
$workerScript = Join-Path $PSScriptRoot "clawusage-auto-worker.ps1"
$defaultStateDir = Join-Path $env:USERPROFILE ".clawusage\.openclaw-state"
$stateDirInput = if (-not [string]::IsNullOrWhiteSpace($env:CLAWUSAGE_STATE_DIR)) { $env:CLAWUSAGE_STATE_DIR } else { $defaultStateDir }
$stateDir = [System.IO.Path]::GetFullPath($stateDirInput)
$configPath = Join-Path $stateDir "clawusage-config.json"
$workerStatePath = Join-Path $stateDir "clawusage-auto-state.json"
$cachePath = Join-Path $stateDir "clawusage-last-snapshot.json"
$sessionsPath = "$env:USERPROFILE\.openclaw\agents\main\sessions\sessions.json"

if (-not (Test-Path -LiteralPath $stateDir)) {
    New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
}
if ($null -eq $Args) {
    $Args = @()
}

function Show-Help {
    Write-Host "clawusage (KISS mode)"
    Write-Host ""
    Write-Host "Usage:"
    Write-Host "  clawusage                     (same as usage)"
    Write-Host "  clawusage usage               (provider quota only)"
    Write-Host "  clawusage now                 (alias of usage)"
    Write-Host "  clawusage status              (alias of usage)"
    Write-Host "  clawusage lang [english|chinese]"
    Write-Host "  clawusage auto on [minutes] [--interval N]   (default interval: 5)"
    Write-Host "  clawusage auto set <minutes>"
    Write-Host "  clawusage auto off"
    Write-Host "  clawusage auto status"
    Write-Host "  clawusage doctor"
    Write-Host "  clawusage -help | --help | -h"
}

function Show-FirstRunLanguageHint {
    Write-Host ""
    Write-Host "Language setup:"
    Write-Host "  EN: use 'clawusage lang english' or 'clawusage lang chinese'."
    Write-Host "  ZH-CN: use 'clawusage lang chinese' to switch to Chinese output."
}

function Normalize-Language {
    param([string]$Language)
    if ([string]::IsNullOrWhiteSpace($Language)) { return "english" }
    $v = $Language.Trim().ToLowerInvariant()
    switch ($v) {
        "en" { return "english" }
        "english" { return "english" }
        "zh" { return "chinese" }
        "cn" { return "chinese" }
        "chinese" { return "chinese" }
        default { throw "Unsupported language: $Language. Use english or chinese." }
    }
}

function Write-LanguageLine {
    param([Parameter(Mandatory = $true)][string]$Language)
    if ($Language -eq "chinese") {
        Write-Host ("语言模式: {0}" -f $Language)
    } else {
        Write-Host ("language: {0}" -f $Language)
    }
}

function Ensure-ConfigProperty {
    param(
        [Parameter(Mandatory = $true)]$Config,
        [Parameter(Mandatory = $true)][string]$Name,
        [Parameter(Mandatory = $true)]$DefaultValue
    )
    if (-not ($Config.PSObject.Properties.Name -contains $Name)) {
        $Config | Add-Member -NotePropertyName $Name -NotePropertyValue $DefaultValue
    }
}

function Get-Config {
    $config = $null
    $needsPersist = $false
    if (Test-Path -LiteralPath $configPath) {
        try { $config = (Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json) } catch {}
    }
    if ($null -eq $config) {
        $config = [pscustomobject]@{
            idleMinutes = 10
            intervalMinutes = 5
            includeLocalTokens = $true
            provider = "openai-codex"
            language = "english"
            configVersion = 2
            onboardingShown = $false
            taskName = $taskName
        }
    }

    Ensure-ConfigProperty -Config $config -Name "idleMinutes" -DefaultValue 10
    Ensure-ConfigProperty -Config $config -Name "intervalMinutes" -DefaultValue 5
    Ensure-ConfigProperty -Config $config -Name "includeLocalTokens" -DefaultValue $true
    Ensure-ConfigProperty -Config $config -Name "provider" -DefaultValue "openai-codex"
    Ensure-ConfigProperty -Config $config -Name "language" -DefaultValue "english"
    Ensure-ConfigProperty -Config $config -Name "configVersion" -DefaultValue 1
    Ensure-ConfigProperty -Config $config -Name "onboardingShown" -DefaultValue $false
    Ensure-ConfigProperty -Config $config -Name "taskName" -DefaultValue $taskName

    # One-time migration: old versions defaulted to 1m checks, which is too noisy.
    if ([int]$config.configVersion -lt 2) {
        if ([int]$config.intervalMinutes -eq 1) {
            $config.intervalMinutes = 5
        }
        $config.configVersion = 2
        $needsPersist = $true
    }

    $config.language = Normalize-Language -Language ([string]$config.language)
    if ($needsPersist) {
        $config | ConvertTo-Json | Set-Content -LiteralPath $configPath -Encoding UTF8
    }
    return $config
}

function Save-Config {
    param([Parameter(Mandatory = $true)]$Config)
    $Config | ConvertTo-Json | Set-Content -LiteralPath $configPath -Encoding UTF8
}

function Quote-Arg {
    param([string]$Value)
    return '"' + ($Value -replace '"', '\"') + '"'
}

function Install-AutoTask {
    param([Parameter(Mandatory = $true)]$Config)

    if (-not (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue)) {
        throw "Register-ScheduledTask cmdlet not found on this system."
    }

    $argParts = @(
        "-WindowStyle Hidden",
        "-NoProfile",
        "-ExecutionPolicy Bypass",
        ("-File {0}" -f (Quote-Arg -Value $workerScript)),
        ("-IdleMinutes {0}" -f [int]$Config.idleMinutes),
        ("-Provider {0}" -f (Quote-Arg -Value ([string]($Config.provider)))),
        ("-Language {0}" -f (Quote-Arg -Value ([string]($Config.language)))),
        ("-SessionsFile {0}" -f (Quote-Arg -Value $sessionsPath)),
        ("-StateFile {0}" -f (Quote-Arg -Value $workerStatePath))
    )
    if ([bool]$Config.includeLocalTokens) {
        $argParts += "-IncludeLocalTokens"
    }
    $taskArgs = $argParts -join " "

    $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $taskArgs
    $trigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) -RepetitionInterval (New-TimeSpan -Minutes ([int]$Config.intervalMinutes)) -RepetitionDuration (New-TimeSpan -Days 3650)
    $settings = New-ScheduledTaskSettingsSet -Hidden -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
    $principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType Interactive -RunLevel Limited

    Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description "clawusage idle reminder (send to latest OpenClaw chat)." -Force | Out-Null
}

function Remove-AutoTask {
    $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
    if ($null -eq $task) { return }
    Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}

function Show-Status {
    $config = Get-Config
    $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
    $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
    Write-Host ("task: {0}" -f $taskName)
    Write-Host ("enabled: {0}" -f ($(if ($null -ne $task) { "on" } else { "off" })))
    Write-Host ("language: {0}" -f [string]$config.language)
    Write-Host ("idleMinutes: {0}" -f [int]$config.idleMinutes)
    Write-Host ("intervalMinutes: {0}" -f [int]$config.intervalMinutes)
    if ($null -ne $task) {
        Write-Host ("state: {0}" -f [string]$task.State)
    }
    if ($null -ne $taskInfo) {
        Write-Host ("lastRun: {0}" -f $taskInfo.LastRunTime)
        Write-Host ("nextRun: {0}" -f $taskInfo.NextRunTime)
        Write-Host ("lastResult: {0}" -f $taskInfo.LastTaskResult)
    }
}

function Show-Doctor {
    $config = Get-Config
    $runtimeSkillPath = "$env:USERPROFILE\.openclaw\workspace\skills\clawusage\SKILL.md"
    $launcher = Get-Command clawusage.cmd -ErrorAction SilentlyContinue
    $autoTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
    $legacyTask = Get-ScheduledTask -TaskName "OpenClawIdleUsagePopup" -ErrorAction SilentlyContinue
    $repairScript = Join-Path $PSScriptRoot "repair-runtime.ps1"

    Write-Host "clawusage doctor"
    Write-Host ("script: {0}" -f $PSCommandPath)
    Write-Host ("stateDir: {0}" -f $stateDir)

    if ($null -ne $launcher) {
        Write-Host ("launcher: ok ({0})" -f $launcher.Source)
    } else {
        Write-Host "launcher: missing from PATH"
    }

    if (Test-Path -LiteralPath $runtimeSkillPath) {
        $skillRaw = Get-Content -LiteralPath $runtimeSkillPath -Raw
        $usesCmd = $skillRaw -match "& clawusage\.cmd"
        $defaultHelp = $skillRaw -match "default to .*help"
        Write-Host ("runtimeSkill: ok ({0})" -f $runtimeSkillPath)
        Write-Host ("runtimeSkill uses clawusage.cmd: {0}" -f $(if ($usesCmd) { "yes" } else { "no" }))
        Write-Host ("runtimeSkill default /clawusage => help: {0}" -f $(if ($defaultHelp) { "yes" } else { "no" }))
    } else {
        Write-Host ("runtimeSkill: missing ({0})" -f $runtimeSkillPath)
    }

    Write-Host ("task {0}: {1}" -f $taskName, $(if ($null -ne $autoTask) { "present" } else { "absent" }))
    Write-Host ("legacy task OpenClawIdleUsagePopup: {0}" -f $(if ($null -ne $legacyTask) { "present" } else { "absent" }))
    Write-Host ("sessions file: {0}" -f $(if (Test-Path -LiteralPath $sessionsPath) { "ok" } else { "missing" }))

    if (Test-Path -LiteralPath $cachePath) {
        $cacheItem = Get-Item -LiteralPath $cachePath
        $ageSec = [int]((Get-Date) - $cacheItem.LastWriteTime).TotalSeconds
        if ($ageSec -lt 0) { $ageSec = 0 }
        Write-Host ("cache: {0}s old ({1})" -f $ageSec, $cachePath)
    } else {
        Write-Host ("cache: missing ({0})" -f $cachePath)
    }

    Write-Host ("config: language={0}, idle={1}m, interval={2}m" -f [string]$config.language, [int]$config.idleMinutes, [int]$config.intervalMinutes)
    Write-Host ("repair: powershell -NoProfile -ExecutionPolicy Bypass -File `"{0}`"" -f $repairScript)
}

function Parse-Interval {
    param([string[]]$Tokens, [int]$Default = 5)
    $interval = $Default
    for ($i = 0; $i -lt $Tokens.Count; $i++) {
        if ($Tokens[$i] -eq "--interval" -and ($i + 1) -lt $Tokens.Count) {
            [void][int]::TryParse($Tokens[$i + 1], [ref]$interval)
        }
    }
    if ($interval -lt 1) { $interval = 1 }
    if ($interval -gt 30) { $interval = 30 }
    return $interval
}

function Parse-MinutesToken {
    param(
        [string]$Token,
        [int]$Default = 0
    )
    if ([string]::IsNullOrWhiteSpace($Token)) { return $Default }
    $t = $Token.Trim().ToLowerInvariant()
    if ($t -match "^(\d+)\s*(m|min|mins|minute|minutes)?$") {
        $n = [int]$matches[1]
        if ($n -ge 1) { return $n }
    }
    return $Default
}

function Invoke-ClawUsageMonitor {
    param(
        [switch]$Json,
        [switch]$Live,
        [switch]$UsageOnly,
        [Parameter(Mandatory = $true)][string]$Language
    )

    $invokeParams = @{
        Language = $Language
    }
    if (-not $UsageOnly) {
        $invokeParams.IncludeLocalTokens = $true
    }
    if ($Json) {
        $invokeParams.Json = $true
    }
    if ($Live) {
        # Backward-compatible hidden switch: force live.
    } else {
        # Simple default: use cache only when it is fresh; otherwise fetch fresh automatically.
        $invokeParams.UseCache = $true
        $invokeParams.CacheMaxAgeSec = 120
    }

    & $monitorScript @invokeParams
}

if (-not (Test-Path -LiteralPath $monitorScript)) {
    throw "Missing script: $monitorScript"
}
if (-not (Test-Path -LiteralPath $workerScript)) {
    throw "Missing script: $workerScript"
}

$cmd = if ($Args.Count -gt 0) { $Args[0].ToLowerInvariant() } else { "usage" }
$config = Get-Config

switch ($cmd) {
    "help" {
        Show-Help
        if (-not [bool]$config.onboardingShown) {
            Show-FirstRunLanguageHint
            $config.onboardingShown = $true
            Save-Config -Config $config
        }
        exit 0
    }
    "-help" {
        Show-Help
        if (-not [bool]$config.onboardingShown) {
            Show-FirstRunLanguageHint
            $config.onboardingShown = $true
            Save-Config -Config $config
        }
        exit 0
    }
    "--help" {
        Show-Help
        if (-not [bool]$config.onboardingShown) {
            Show-FirstRunLanguageHint
            $config.onboardingShown = $true
            Save-Config -Config $config
        }
        exit 0
    }
    "-h" {
        Show-Help
        if (-not [bool]$config.onboardingShown) {
            Show-FirstRunLanguageHint
            $config.onboardingShown = $true
            Save-Config -Config $config
        }
        exit 0
    }
    "now" {
        $json = ($Args -contains "--json")
        Invoke-ClawUsageMonitor -Json:$json -UsageOnly -Language $config.language
        exit 0
    }
    "usage" {
        $json = ($Args -contains "--json")
        Invoke-ClawUsageMonitor -Json:$json -UsageOnly -Language $config.language
        exit 0
    }
    "status" {
        Invoke-ClawUsageMonitor -UsageOnly -Language $config.language
        exit 0
    }
    "doctor" {
        Show-Doctor
        exit 0
    }
    "lang" {
        if ($Args.Count -lt 2) {
            Write-LanguageLine -Language ([string]$config.language)
            exit 0
        }

        $language = Normalize-Language -Language $Args[1]
        $config.language = $language
        $config.onboardingShown = $true
        Save-Config -Config $config

        $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
        if ($null -ne $task) {
            Install-AutoTask -Config $config
        }

        Write-LanguageLine -Language $language
        exit 0
    }
    "auto" {
        if ($Args.Count -lt 2) {
            Show-Help
            if (-not [bool]$config.onboardingShown) {
                Show-FirstRunLanguageHint
                $config.onboardingShown = $true
                Save-Config -Config $config
            }
            exit 1
        }
        $sub = $Args[1].ToLowerInvariant()
        switch ($sub) {
            "on" {
                if ($Args.Count -ge 3) {
                    $tmp = Parse-MinutesToken -Token $Args[2] -Default 0
                    if ($tmp -ge 1) {
                        $config.idleMinutes = $tmp
                    }
                }
                $config.intervalMinutes = Parse-Interval -Tokens $Args -Default ([int]$config.intervalMinutes)
                Save-Config -Config $config
                Install-AutoTask -Config $config
                Write-Host ("auto: on (idle={0}m, interval={1}m)" -f [int]$config.idleMinutes, [int]$config.intervalMinutes)
                exit 0
            }
            "set" {
                if ($Args.Count -lt 3) {
                    throw "Usage: clawusage auto set <minutes>"
                }
                $minutes = Parse-MinutesToken -Token $Args[2] -Default 0
                if ($minutes -lt 1) {
                    throw "Idle minutes must be an integer >= 1."
                }
                $config.idleMinutes = $minutes
                Save-Config -Config $config
                $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
                if ($null -ne $task) {
                    Install-AutoTask -Config $config
                    Write-Host ("auto: threshold updated to {0}m (task reloaded)" -f $minutes)
                } else {
                    Write-Host ("auto: threshold saved ({0}m). run clawusage auto on to enable." -f $minutes)
                }
                exit 0
            }
            "off" {
                Remove-AutoTask
                Write-Host "auto: off"
                exit 0
            }
            "status" {
                Show-Status
                exit 0
            }
            default {
                Show-Help
                if (-not [bool]$config.onboardingShown) {
                    Show-FirstRunLanguageHint
                    $config.onboardingShown = $true
                    Save-Config -Config $config
                }
                exit 1
            }
        }
    }
    default {
        Show-Help
        if (-not [bool]$config.onboardingShown) {
            Show-FirstRunLanguageHint
            $config.onboardingShown = $true
            Save-Config -Config $config
        }
        exit 1
    }
}



path: scripts/openclaw-usage-monitor.ps1.txt
content: [CmdletBinding()]
param(
    [switch]$Watch,
    [int]$IntervalSec = 30,
    [string]$Provider = "",
    [switch]$Json,
    [switch]$NoClear,
    [switch]$IncludeLocalTokens,
    [ValidateSet("english", "chinese")]
    [string]$Language = "english",
    [switch]$UseCache,
    [int]$CacheMaxAgeSec = 300,
    [switch]$AllowStaleCache,
    [string]$CacheFile = ""
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

try {
    $utf8 = New-Object System.Text.UTF8Encoding($false)
    [Console]::InputEncoding = $utf8
    [Console]::OutputEncoding = $utf8
    $OutputEncoding = $utf8
} catch {
    # Best effort only; continue even if host blocks encoding changes.
}

if ([string]::IsNullOrWhiteSpace($CacheFile)) {
    $defaultStateDir = Join-Path $env:USERPROFILE ".clawusage\.openclaw-state"
    $stateDirInput = if (-not [string]::IsNullOrWhiteSpace($env:CLAWUSAGE_STATE_DIR)) { $env:CLAWUSAGE_STATE_DIR } else { $defaultStateDir }
    $CacheFile = [System.IO.Path]::GetFullPath((Join-Path $stateDirInput "clawusage-last-snapshot.json"))
}

function Is-ChineseMode {
    return ($Language -eq "chinese")
}

function T {
    param([Parameter(Mandatory = $true)][string]$Key)
    $zh = Is-ChineseMode
    switch ($Key) {
        "title" { if ($zh) { return "📊 OpenClaw 用量监控" } else { return "OpenClaw Usage Monitor" } }
        "updated" { if ($zh) { return "更新时间" } else { return "Updated" } }
        "languageMode" { if ($zh) { return "语言模式" } else { return "Language mode" } }
        "source" { if ($zh) { return "数据来源" } else { return "Source" } }
        "sourceLive" { if ($zh) { return "实时" } else { return "live" } }
        "sourceCache" { if ($zh) { return "缓存" } else { return "cache" } }
        "sourceStale" { if ($zh) { return "过期" } else { return "stale" } }
        "quota" { if ($zh) { return "📉 配额" } else { return "Quota" } }
        "noQuotaRows" { if ($zh) { return "没有可展示的配额数据。" } else { return "No usage rows matched filter." } }
        "used" { if ($zh) { return "已用" } else { return "used" } }
        "left" { if ($zh) { return "剩余" } else { return "left" } }
        "resetAt" { if ($zh) { return "重置" } else { return "reset" } }
        "timeLeft" { if ($zh) { return "剩余时间" } else { return "left" } }
        "providerWindowNote" {
            if ($zh) { return "Day 标签由服务端定义，重置时间以 resetAt 为准。" } else { return "Day label is provider-defined; trust resetAt." }
        }
        "session" { if ($zh) { return "🧠 当前会话" } else { return "Current session" } }
        "model" { if ($zh) { return "🤖 模型" } else { return "Model" } }
        "tokens" { if ($zh) { return "🔢 Token" } else { return "Tokens" } }
        "context" { if ($zh) { return "🧩 上下文" } else { return "Context" } }
        "compactions" { if ($zh) { return "🗜️ 压缩次数" } else { return "Compactions" } }
        "time" { if ($zh) { return "🕒 时间" } else { return "Time" } }
        "sessionKey" { if ($zh) { return "🧷 会话" } else { return "Session key" } }
        "reasoning" { if ($zh) { return "🧠 推理" } else { return "Reasoning" } }
        "na" { if ($zh) { return "无" } else { return "n/a" } }
        "localTokens" { if ($zh) { return "🧾 本地 Token 汇总（来自本机日志）" } else { return "Local token summary (from local logs)" } }
        "today" { if ($zh) { return "📅 今日" } else { return "Today" } }
        "last7d" { if ($zh) { return "🗓️ 近7天" } else { return "Last 7d" } }
        "last30d" { if ($zh) { return "🗓️ 近30天" } else { return "Last 30d" } }
        "messages" { if ($zh) { return "💬 消息数" } else { return "Messages" } }
        "refreshIn" { if ($zh) { return "将在 {0}s 后刷新，Ctrl+C 结束。" } else { return "Refresh in {0}s. Press Ctrl+C to stop." } }
        "resetting" { if ($zh) { return "重置中" } else { return "resetting" } }
        default { return $Key }
    }
}

function Format-Count {
    param([nullable[int64]]$Value)
    if ($null -eq $Value) { return (T "na") }
    return ([int64]$Value).ToString("N0")
}

function Format-TimeLeft {
    param([timespan]$Span)
    if ($Span.TotalSeconds -le 0) { return (T "resetting") }
    if ($Span.TotalDays -ge 1) {
        return ("{0}d {1}h {2}m" -f [int]$Span.TotalDays, $Span.Hours, $Span.Minutes)
    }
    if ($Span.TotalHours -ge 1) {
        return ("{0}h {1}m {2}s" -f [int]$Span.TotalHours, $Span.Minutes, $Span.Seconds)
    }
    if ($Span.TotalMinutes -ge 1) {
        return ("{0}m {1}s" -f [int]$Span.TotalMinutes, $Span.Seconds)
    }
    return ("{0}s" -f [int]$Span.TotalSeconds)
}

function Clamp-Percent {
    param([double]$Value)
    if ($Value -lt 0) { return 0 }
    if ($Value -gt 100) { return 100 }
    return [int][Math]::Round($Value)
}

function Resolve-CodexSecondaryWindowLabel {
    param(
        [int]$WindowHours,
        [nullable[double]]$PrimaryResetAtSec,
        [nullable[double]]$SecondaryResetAtSec
    )
    $weeklyGapSeconds = 4320 * 60
    if ($WindowHours -ge 168) { return "Week" }
    if ($WindowHours -lt 24) { return ("{0}h" -f $WindowHours) }
    if ($PrimaryResetAtSec -ne $null -and $SecondaryResetAtSec -ne $null) {
        if (([double]$SecondaryResetAtSec - [double]$PrimaryResetAtSec) -ge $weeklyGapSeconds) {
            return "Week"
        }
    }
    return "Day"
}

function Get-CodexAuthToken {
    $authPath = Join-Path $env:USERPROFILE ".openclaw\agents\main\agent\auth-profiles.json"
    if (-not (Test-Path -LiteralPath $authPath)) { return $null }

    try {
        $auth = Get-Content -LiteralPath $authPath -Raw | ConvertFrom-Json
    } catch {
        return $null
    }
    if ($null -eq $auth -or $null -eq $auth.profiles) { return $null }

    $profileId = $null
    if ($auth.PSObject.Properties.Name -contains "lastGood" -and $null -ne $auth.lastGood) {
        if ($auth.lastGood.PSObject.Properties.Name -contains "openai-codex") {
            $profileId = [string]$auth.lastGood.'openai-codex'
        }
    }

    $profile = $null
    if (-not [string]::IsNullOrWhiteSpace($profileId) -and ($auth.profiles.PSObject.Properties.Name -contains $profileId)) {
        $profile = $auth.profiles.$profileId
    } else {
        foreach ($p in $auth.profiles.PSObject.Properties) {
            if ($null -eq $p.Value) { continue }
            if ($p.Value.PSObject.Properties.Name -contains "provider" -and [string]$p.Value.provider -eq "openai-codex") {
                $profile = $p.Value
                break
            }
        }
    }

    if ($null -eq $profile) { return $null }
    $token = if ($profile.PSObject.Properties.Name -contains "access") { [string]$profile.access } else { "" }
    if ([string]::IsNullOrWhiteSpace($token)) { return $null }
    $accountId = if ($profile.PSObject.Properties.Name -contains "accountId") { [string]$profile.accountId } else { "" }
    return [pscustomobject]@{
        [REDACTED_SECRET]
        AccountId = $accountId
    }
}

function Get-CodexUsageSnapshotDirect {
    param([string]$ProviderFilter = "")

    if (-not [string]::IsNullOrWhiteSpace($ProviderFilter) -and $ProviderFilter -ne "openai-codex") {
        return $null
    }

    $auth = Get-CodexAuthToken
    if ($null -eq $auth) { return $null }

    $headers = @{
        Authorization = ("Bearer {0}" -f $auth.Token)
        "User-Agent" = "CodexBar"
        Accept = "application/json"
    }
    if (-not [string]::IsNullOrWhiteSpace($auth.AccountId)) {
        $headers["ChatGPT-Account-Id"] = $auth.AccountId
    }

    try {
        $resp = Invoke-RestMethod -Uri "https://chatgpt.com/backend-api/wham/usage" -Headers $headers -Method GET -TimeoutSec 6
    } catch {
        return $null
    }
    if ($null -eq $resp -or $null -eq $resp.rate_limit) { return $null }

    $rows = @()
    $plan = if ($resp.PSObject.Properties.Name -contains "plan_type") { [string]$resp.plan_type } else { "" }
    $primaryResetAtSec = $null

    $primary = $resp.rate_limit.primary_window
    if ($null -ne $primary) {
        $limitSec = if ($primary.PSObject.Properties.Name -contains "limit_window_seconds" -and $primary.limit_window_seconds -ne $null) {
            [int]$primary.limit_window_seconds
        } else { 10800 }
        $hours = [int][Math]::Round($limitSec / 3600)
        if ($hours -lt 1) { $hours = 1 }

        $used = Clamp-Percent -Value $(if ($primary.PSObject.Properties.Name -contains "used_percent" -and $primary.used_percent -ne $null) { [double]$primary.used_percent } else { 0 })
        $remain = [Math]::Max(0, 100 - $used)
        $resetSec = if ($primary.PSObject.Properties.Name -contains "reset_at" -and $primary.reset_at -ne $null) { [double]$primary.reset_at } else { $null }
        $primaryResetAtSec = $resetSec
        $reset = if ($resetSec -ne $null) { [datetimeoffset]::FromUnixTimeSeconds([int64]$resetSec).ToLocalTime() } else { $null }
        $left = if ($reset -ne $null) { $reset - [datetimeoffset]::Now } else { [timespan]::Zero }

        $rows += [pscustomobject]@{
            Provider = "openai-codex"
            Plan = $plan
            Window = ("{0}h" -f $hours)
            UsedPercent = $used
            RemainingPercent = $remain
            ResetAt = $(if ($reset -ne $null) { $reset.ToString("yyyy-MM-dd HH:mm:ss zzz") } else { "n/a" })
            TimeLeft = $(if ($reset -ne $null) { Format-TimeLeft -Span $left } else { "n/a" })
            ResetAtUnix = $(if ($reset -ne $null) { [int64]$reset.ToUnixTimeSeconds() } else { [int64]0 })
        }
    }

    $secondary = $resp.rate_limit.secondary_window
    if ($null -ne $secondary) {
        $limitSec = if ($secondary.PSObject.Properties.Name -contains "limit_window_seconds" -and $secondary.limit_window_seconds -ne $null) {
            [int]$secondary.limit_window_seconds
        } else { 86400 }
        $hours = [int][Math]::Round($limitSec / 3600)
        if ($hours -lt 1) { $hours = 1 }

        $used = Clamp-Percent -Value $(if ($secondary.PSObject.Properties.Name -contains "used_percent" -and $secondary.used_percent -ne $null) { [double]$secondary.used_percent } else { 0 })
        $remain = [Math]::Max(0, 100 - $used)
        $resetSec = if ($secondary.PSObject.Properties.Name -contains "reset_at" -and $secondary.reset_at -ne $null) { [double]$secondary.reset_at } else { $null }
        $label = Resolve-CodexSecondaryWindowLabel -WindowHours $hours -PrimaryResetAtSec $primaryResetAtSec -SecondaryResetAtSec $resetSec
        $reset = if ($resetSec -ne $null) { [datetimeoffset]::FromUnixTimeSeconds([int64]$resetSec).ToLocalTime() } else { $null }
        $left = if ($reset -ne $null) { $reset - [datetimeoffset]::Now } else { [timespan]::Zero }

        $rows += [pscustomobject]@{
            Provider = "openai-codex"
            Plan = $plan
            Window = $label
            UsedPercent = $used
            RemainingPercent = $remain
            ResetAt = $(if ($reset -ne $null) { $reset.ToString("yyyy-MM-dd HH:mm:ss zzz") } else { "n/a" })
            TimeLeft = $(if ($reset -ne $null) { Format-TimeLeft -Span $left } else { "n/a" })
            ResetAtUnix = $(if ($reset -ne $null) { [int64]$reset.ToUnixTimeSeconds() } else { [int64]0 })
        }
    }

    if (($rows | Measure-Object).Count -eq 0) { return $null }
    return [pscustomobject]@{
        UpdatedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss zzz")
        Rows = $rows
        Session = (Get-SessionSummary)
    }
}

function Get-CachedSnapshot {
    if (-not $UseCache) { return $null }
    if (-not (Test-Path -LiteralPath $CacheFile)) { return $null }
    if ($CacheMaxAgeSec -lt 1) { return $null }

    try {
        $obj = Get-Content -LiteralPath $CacheFile -Raw | ConvertFrom-Json
        if ($null -eq $obj) { return $null }
        if (-not ($obj.PSObject.Properties.Name -contains "cachedAt")) { return $null }
        if (-not ($obj.PSObject.Properties.Name -contains "snapshot")) { return $null }
        $cachedAt = [datetimeoffset]::Parse([string]$obj.cachedAt)
        $ageSec = [int]([datetimeoffset]::Now - $cachedAt).TotalSeconds
        if ($ageSec -lt 0) { $ageSec = 0 }
        $stale = $ageSec -gt $CacheMaxAgeSec
        if ($stale -and -not $AllowStaleCache) { return $null }

        $snap = $obj.snapshot
        if ($null -eq $snap) { return $null }
        $snap | Add-Member -NotePropertyName FromCache -NotePropertyValue $true -Force
        $snap | Add-Member -NotePropertyName CacheAgeSec -NotePropertyValue $ageSec -Force
        $snap | Add-Member -NotePropertyName CacheStale -NotePropertyValue $stale -Force
        return $snap
    } catch {
        return $null
    }
}

function Save-SnapshotCache {
    param([Parameter(Mandatory = $true)]$Snapshot)

    try {
        $cacheDir = Split-Path -Parent $CacheFile
        if (-not [string]::IsNullOrWhiteSpace($cacheDir) -and -not (Test-Path -LiteralPath $cacheDir)) {
            New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
        }

        [pscustomobject]@{
            cachedAt = [datetimeoffset]::Now.ToString("o")
            snapshot = $Snapshot
        } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $CacheFile -Encoding UTF8
    } catch {
        # Cache is best-effort only.
    }
}

function Get-LocalTokenSummary {
    $sessionDir = Join-Path $env:USERPROFILE ".openclaw\agents\main\sessions"
    if (-not (Test-Path -LiteralPath $sessionDir)) {
        return [pscustomobject]@{
            SessionDir = $sessionDir
            Exists = $false
            TodayTokens = 0
            Last7DaysTokens = 0
            Last30DaysTokens = 0
            MessageCount = 0
        }
    }

    $todayStart = (Get-Date).Date
    $d7 = $todayStart.AddDays(-6)
    $d30 = $todayStart.AddDays(-29)

    $today = [int64]0
    $last7 = [int64]0
    $last30 = [int64]0
    $count = [int64]0

    $files = Get-ChildItem -LiteralPath $sessionDir -File | Where-Object { $_.Name -like "*.jsonl*" }
    foreach ($file in $files) {
        Get-Content -LiteralPath $file.FullName | ForEach-Object {
            if ([string]::IsNullOrWhiteSpace($_)) { return }
            try { $obj = $_ | ConvertFrom-Json -ErrorAction Stop } catch { return }
            if ($obj.type -ne "message") { return }
            if ($null -eq $obj.message) { return }
            if (-not ($obj.message.PSObject.Properties.Name -contains "provider")) { return }
            if ([string]$obj.message.provider -ne "openai-codex") { return }
            $ts = $null
            try { $ts = [datetimeoffset]::Parse([string]$obj.timestamp).LocalDateTime } catch { return }
            $u = $obj.message.usage
            if ($null -eq $u) { return }

            $tokens = [int64]0
            if ($u.PSObject.Properties.Name -contains "totalTokens" -and $u.totalTokens -ne $null) {
                $tokens = [int64]$u.totalTokens
            } elseif ($u.PSObject.Properties.Name -contains "total" -and $u.total -ne $null) {
                $tokens = [int64]$u.total
            } else {
                $in = if ($u.PSObject.Properties.Name -contains "input" -and $u.input -ne $null) { [int64]$u.input } else { 0 }
                $out = if ($u.PSObject.Properties.Name -contains "output" -and $u.output -ne $null) { [int64]$u.output } else { 0 }
                $cr = if ($u.PSObject.Properties.Name -contains "cacheRead" -and $u.cacheRead -ne $null) { [int64]$u.cacheRead } else { 0 }
                $cw = if ($u.PSObject.Properties.Name -contains "cacheWrite" -and $u.cacheWrite -ne $null) { [int64]$u.cacheWrite } else { 0 }
                $tokens = $in + $out + $cr + $cw
            }
            if ($tokens -le 0) { return }

            $count++
            if ($ts -ge $d30) { $last30 += $tokens }
            if ($ts -ge $d7) { $last7 += $tokens }
            if ($ts -ge $todayStart) { $today += $tokens }
        }
    }

    return [pscustomobject]@{
        SessionDir = $sessionDir
        Exists = $true
        TodayTokens = $today
        Last7DaysTokens = $last7
        Last30DaysTokens = $last30
        MessageCount = $count
    }
}

function Get-SessionSummary {
    $sessionsPath = Join-Path $env:USERPROFILE ".openclaw\agents\main\sessions\sessions.json"
    if (-not (Test-Path -LiteralPath $sessionsPath)) { return $null }

    $all = $null
    try {
        $all = Get-Content -LiteralPath $sessionsPath -Raw | ConvertFrom-Json
    } catch {
        return $null
    }
    if ($null -eq $all) { return $null }

    $bestKey = ""
    $bestValue = $null
    $bestUpdatedMs = [int64]0
    foreach ($p in $all.PSObject.Properties) {
        $v = $p.Value
        if ($null -eq $v) { continue }
        if (-not ($v.PSObject.Properties.Name -contains "updatedAt")) { continue }

        $updatedMs = [int64]$v.updatedAt
        if ($updatedMs -gt $bestUpdatedMs) {
            $bestUpdatedMs = $updatedMs
            $bestKey = [string]$p.Name
            $bestValue = $v
        }
    }
    if ($null -eq $bestValue) { return $null }

    $model = if ($bestValue.PSObject.Properties.Name -contains "model") { [string]$bestValue.model } else { "" }
    $provider = if ($bestValue.PSObject.Properties.Name -contains "modelProvider") { [string]$bestValue.modelProvider } else { "" }
    $inputTokens = if ($bestValue.PSObject.Properties.Name -contains "inputTokens" -and $bestValue.inputTokens -ne $null) { [int64]$bestValue.inputTokens } else { $null }
    $outputTokens = if ($bestValue.PSObject.Properties.Name -contains "outputTokens" -and $bestValue.outputTokens -ne $null) { [int64]$bestValue.outputTokens } else { $null }
    $totalTokens = if ($bestValue.PSObject.Properties.Name -contains "totalTokens" -and $bestValue.totalTokens -ne $null) { [int64]$bestValue.totalTokens } else { $null }
    $contextMax = if ($bestValue.PSObject.Properties.Name -contains "contextTokens" -and $bestValue.contextTokens -ne $null) { [int64]$bestValue.contextTokens } else { $null }
    $compactions = if ($bestValue.PSObject.Properties.Name -contains "compactionCount" -and $bestValue.compactionCount -ne $null) { [int]$bestValue.compactionCount } else { 0 }
    $reasoning = if ($bestValue.PSObject.Properties.Name -contains "reasoning" -and $bestValue.reasoning -ne $null -and -not [string]::IsNullOrWhiteSpace([string]$bestValue.reasoning)) { [string]$bestValue.reasoning } else { "off" }

    $contextUsed = $totalTokens
    if ($null -eq $contextUsed -and $inputTokens -ne $null -and $outputTokens -ne $null) {
        $contextUsed = [int64]$inputTokens + [int64]$outputTokens
    }
    $contextPct = $null
    if ($contextUsed -ne $null -and $contextMax -ne $null -and [int64]$contextMax -gt 0) {
        $contextPct = [int][Math]::Round(([double]$contextUsed * 100.0) / [double]$contextMax)
    }

    $updatedAtLocal = [datetimeoffset]::FromUnixTimeMilliseconds($bestUpdatedMs).ToLocalTime()
    return [pscustomobject]@{
        SessionKey = $bestKey
        UpdatedAt = $updatedAtLocal.ToString("yyyy-MM-dd HH:mm:ss zzz")
        Model = $model
        Provider = $provider
        InputTokens = $inputTokens
        OutputTokens = $outputTokens
        TotalTokens = $totalTokens
        ContextUsed = $contextUsed
        ContextMax = $contextMax
        ContextPercent = $contextPct
        Compactions = $compactions
        Reasoning = $reasoning
    }
}

function Get-UsageSnapshot {
    $direct = Get-CodexUsageSnapshotDirect -ProviderFilter $Provider
    if ($null -ne $direct) {
        if ($IncludeLocalTokens) {
            $direct | Add-Member -NotePropertyName LocalTokens -NotePropertyValue (Get-LocalTokenSummary) -Force
        }
        return $direct
    }

    $usageObj = $null
    $rawUsage = (& cmd /c "openclaw gateway call usage.status --json --timeout 5000 2>nul" | Out-String).Trim()
    if (-not [string]::IsNullOrWhiteSpace($rawUsage)) {
        try {
            $parsedUsage = $rawUsage | ConvertFrom-Json
            if ($null -ne $parsedUsage -and ($parsedUsage.PSObject.Properties.Name -contains "providers")) {
                $usageObj = $parsedUsage
            }
        } catch {
            $usageObj = $null
        }
    }

    if ($null -eq $usageObj) {
        $raw = (& cmd /c "openclaw channels list --json 2>nul" | Out-String).Trim()
        if ([string]::IsNullOrWhiteSpace($raw)) {
            throw "No JSON from usage.status or channels list."
        }
        $obj = $raw | ConvertFrom-Json
        if ($null -eq $obj.usage -or $null -eq $obj.usage.providers) {
            throw "Usage data not found in OpenClaw output."
        }
        $usageObj = $obj.usage
    }

    $updatedAtMs = [int64]0
    if ($usageObj.PSObject.Properties.Name -contains "updatedAt" -and $usageObj.updatedAt -ne $null) {
        $updatedAtMs = [int64]$usageObj.updatedAt
    }
    $updated = if ($updatedAtMs -gt 0) {
        [datetimeoffset]::FromUnixTimeMilliseconds($updatedAtMs).ToLocalTime()
    } else {
        [datetimeoffset]::Now
    }

    $rows = @()
    foreach ($p in $usageObj.providers) {
        if (-not [string]::IsNullOrWhiteSpace($Provider) -and $p.provider -ne $Provider) { continue }
        foreach ($w in $p.windows) {
            $used = [int]$w.usedPercent
            $remain = [Math]::Max(0, 100 - $used)
            $reset = [datetimeoffset]::FromUnixTimeMilliseconds([int64]$w.resetAt).ToLocalTime()
            $left = $reset - [datetimeoffset]::Now
            $rows += [pscustomobject]@{
                Provider = [string]$p.provider
                Plan = [string]$p.plan
                Window = [string]$w.label
                UsedPercent = $used
                RemainingPercent = $remain
                ResetAt = $reset.ToString("yyyy-MM-dd HH:mm:ss zzz")
                TimeLeft = (Format-TimeLeft -Span $left)
                ResetAtUnix = [int64]$reset.ToUnixTimeSeconds()
            }
        }
    }

    $result = [pscustomobject]@{
        UpdatedAt = $updated.ToString("yyyy-MM-dd HH:mm:ss zzz")
        Rows = $rows
        Session = (Get-SessionSummary)
    }
    if ($IncludeLocalTokens) {
        $result | Add-Member -NotePropertyName LocalTokens -NotePropertyValue (Get-LocalTokenSummary)
    }
    return $result
}

function Show-Snapshot {
    param([Parameter(Mandatory = $true)]$Snapshot)
    Write-Host ("{0} | {1}: {2}" -f (T "title"), (T "updated"), $Snapshot.UpdatedAt)
    Write-Host ("{0}: {1}" -f (T "languageMode"), $Language)

    if ($Snapshot.PSObject.Properties.Name -contains "FromCache" -and [bool]$Snapshot.FromCache) {
        if ($Snapshot.PSObject.Properties.Name -contains "CacheStale" -and [bool]$Snapshot.CacheStale) {
            Write-Host ("{0}: {1} ({2}s, {3})" -f (T "source"), (T "sourceCache"), [int]$Snapshot.CacheAgeSec, (T "sourceStale"))
        } else {
            Write-Host ("{0}: {1} ({2}s)" -f (T "source"), (T "sourceCache"), [int]$Snapshot.CacheAgeSec)
        }
    } else {
        Write-Host ("{0}: {1}" -f (T "source"), (T "sourceLive"))
    }

    if ($Snapshot.PSObject.Properties.Name -contains "Session" -and $null -ne $Snapshot.Session) {
        $s = $Snapshot.Session
        $na = (T "na")
        Write-Host ""
        Write-Host ((T "session") + ":")

        $modelLine = $na
        if (-not [string]::IsNullOrWhiteSpace([string]$s.Provider) -and -not [string]::IsNullOrWhiteSpace([string]$s.Model)) {
            $modelLine = ("{0}/{1}" -f [string]$s.Provider, [string]$s.Model)
        } elseif (-not [string]::IsNullOrWhiteSpace([string]$s.Model)) {
            $modelLine = [string]$s.Model
        } elseif (-not [string]::IsNullOrWhiteSpace([string]$s.Provider)) {
            $modelLine = [string]$s.Provider
        }
        Write-Host ("- {0}: {1}" -f (T "model"), $modelLine)

        $inPart = if ($null -ne $s.InputTokens) { Format-Count -Value ([int64]$s.InputTokens) } else { $na }
        $outPart = if ($null -ne $s.OutputTokens) { Format-Count -Value ([int64]$s.OutputTokens) } else { $na }
        if (Is-ChineseMode) {
            $tokenLine = ("输入 {0} / 输出 {1}" -f $inPart, $outPart)
        } else {
            $tokenLine = ("in {0} / out {1}" -f $inPart, $outPart)
        }
        if ($null -ne $s.TotalTokens) {
            if (Is-ChineseMode) {
                $tokenLine += (" / 总计 {0}" -f (Format-Count -Value ([int64]$s.TotalTokens)))
            } else {
                $tokenLine += (" / total {0}" -f (Format-Count -Value ([int64]$s.TotalTokens)))
            }
        }
        Write-Host ("- {0}: {1}" -f (T "tokens"), $tokenLine)

        $ctxUsed = if ($null -ne $s.ContextUsed) { Format-Count -Value ([int64]$s.ContextUsed) } else { $na }
        $ctxMax = if ($null -ne $s.ContextMax) { Format-Count -Value ([int64]$s.ContextMax) } else { $na }
        $ctxPct = if ($null -ne $s.ContextPercent) { ("{0}%" -f [int]$s.ContextPercent) } else { $na }
        Write-Host ("- {0}: {1} / {2} ({3})" -f (T "context"), $ctxUsed, $ctxMax, $ctxPct)

        $compactions = if ($null -ne $s.Compactions) { [string]$s.Compactions } else { $na }
        Write-Host ("- {0}: {1}" -f (T "compactions"), $compactions)
        Write-Host ("- {0}: {1}" -f (T "reasoning"), $(if (-not [string]::IsNullOrWhiteSpace([string]$s.Reasoning)) { [string]$s.Reasoning } else { $na }))
        Write-Host ("- {0}: {1}" -f (T "sessionKey"), $(if (-not [string]::IsNullOrWhiteSpace([string]$s.SessionKey)) { [string]$s.SessionKey } else { $na }))
        Write-Host ("- {0}: {1}" -f (T "time"), $(if (-not [string]::IsNullOrWhiteSpace([string]$s.UpdatedAt)) { [string]$s.UpdatedAt } else { $na }))
    }

    if (($Snapshot.Rows | Measure-Object).Count -eq 0) {
        Write-Host ""
        Write-Host (T "noQuotaRows")
        return
    }

    Write-Host ""
    Write-Host ((T "quota") + ":")
    foreach ($r in $Snapshot.Rows) {
        $providerPlan = [string]$r.Provider
        if (-not [string]::IsNullOrWhiteSpace([string]$r.Plan)) {
            $providerPlan = ("{0}/{1}" -f $providerPlan, [string]$r.Plan)
        }

        if (Is-ChineseMode) {
            Write-Host ("- {0} {1}: 🔥 {2} {3}% | ✅ {4} {5}% | ⏰ {6} {7} | ⌛ {8} {9}" -f $providerPlan, [string]$r.Window, (T "used"), [int]$r.UsedPercent, (T "left"), [int]$r.RemainingPercent, (T "resetAt"), [string]$r.ResetAt, (T "timeLeft"), [string]$r.TimeLeft)
        } else {
            Write-Host ("- {0} {1}: {2}% {3} | {4}% {5} | {6} {7} | {8} {9}" -f $providerPlan, [string]$r.Window, [int]$r.UsedPercent, (T "used"), [int]$r.RemainingPercent, (T "left"), (T "resetAt"), [string]$r.ResetAt, (T "timeLeft"), [string]$r.TimeLeft)
        }

        if ([string]$r.Window -ieq "Day" -and $r.PSObject.Properties.Name -contains "ResetAtUnix" -and [int64]$r.ResetAtUnix -gt 0) {
            $reset = [datetimeoffset]::FromUnixTimeSeconds([int64]$r.ResetAtUnix).ToLocalTime()
            $hoursUntilReset = ($reset - [datetimeoffset]::Now).TotalHours
            if ($hoursUntilReset -gt 36) {
                Write-Host ("  - {0}" -f (T "providerWindowNote"))
            }
        }
    }

    if ($IncludeLocalTokens -and $null -ne $Snapshot.LocalTokens) {
        Write-Host ""
        Write-Host ((T "localTokens") + ":")
        Write-Host ("- {0}: {1}" -f (T "today"), (Format-Count -Value ([int64]$Snapshot.LocalTokens.TodayTokens)))
        Write-Host ("- {0}: {1}" -f (T "last7d"), (Format-Count -Value ([int64]$Snapshot.LocalTokens.Last7DaysTokens)))
        Write-Host ("- {0}: {1}" -f (T "last30d"), (Format-Count -Value ([int64]$Snapshot.LocalTokens.Last30DaysTokens)))
        Write-Host ("- {0}: {1}" -f (T "messages"), (Format-Count -Value ([int64]$Snapshot.LocalTokens.MessageCount)))
    }
}

if ($IntervalSec -lt 2) {
    throw "IntervalSec must be >= 2."
}

if ($Watch) {
    while ($true) {
        if (-not $NoClear) { Clear-Host }
        try {
            $snap = Get-UsageSnapshot
            if ($UseCache) {
                Save-SnapshotCache -Snapshot $snap
            }
            if ($Json) {
                $snap | ConvertTo-Json -Depth 8
            } else {
                Show-Snapshot -Snapshot $snap
                Write-Host ((T "refreshIn") -f $IntervalSec)
            }
        } catch {
            Write-Host ("[error] " + $_.Exception.Message) -ForegroundColor Red
        }
        Start-Sleep -Seconds $IntervalSec
    }
}

$single = Get-CachedSnapshot
if ($null -eq $single) {
    $single = Get-UsageSnapshot
    if ($UseCache) {
        Save-SnapshotCache -Snapshot $single
    }
}
if ($Json) {
    $single | ConvertTo-Json -Depth 8
} else {
    Show-Snapshot -Snapshot $single
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)