Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ on:
pull_request:

jobs:

quota-state-platforms:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test ./internal/codexstate

build:
runs-on: ubuntu-latest
steps:
Expand Down
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ after the terminal closes.

```
claude ✓ pinged (6.6s)
codex ✓ pinged (13.6s)
spark ✓ pinged (12.4s)
codex CLI trigger returned without error (13.6s); turn completion is not verified
spark CLI trigger returned without error (12.4s); turn completion is not verified
```

## Highlights
Expand Down Expand Up @@ -112,6 +112,27 @@ and pings as soon as the window resets.
Claude/Codex tokens are reused from the official tools (no separate login) and
refreshed on 401. Spark reuses the Codex token.

### Codex/Spark window verification

At 0% usage, one quota API response cannot always tell whether a window has started.
For example, compare these five-hour reset times:

| Pattern (both show 0% used) | Read at 10:00 | Read at 10:01 |
| --- | --- | --- |
| Started: reset stays fixed | 15:00 | 15:00 |
| Not started: reset slides forward | 15:00 | 15:01 |

To distinguish these patterns, limitping compares reads at least one minute apart.
Inconclusive results stay unconfirmed. `ping` returns without waiting that minute
and suggests a later `status` check; `watch`/`bg` rechecks automatically.

If the pre-ping quota check fails, both manual and automatic pings report the
reason and stop without sending. Authentication reload/refresh on HTTP 401 is
still attempted. Only the watcher waits before retrying: authentication/permission
failures back off from 30 seconds to one hour; other read failures cap at ten
minutes, with `Retry-After` respected. Restart the watcher to retry immediately
after fixing access. Manual pings do not wait through this backoff.

## Install

`limitping` ships as a single self-contained binary — **no Go required**.
Expand Down Expand Up @@ -232,9 +253,9 @@ elapsed time only:
claude → claude --model haiku .
claude ✓ pinged (6.6s)
codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok
codex ✓ pinged (13.6s)
codex CLI trigger returned without error (13.6s); turn completion is not verified
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok
spark ✓ pinged (12.4s)
spark CLI trigger returned without error (12.4s); turn completion is not verified
```

Use `status` or `bg status` for the authoritative 5h/weekly window view after a
Expand Down
27 changes: 23 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ Claude Code、Codex 和 Spark 的订阅限额按 **5 小时滚动窗口**(外加

```
claude ✓ pinged (6.6s)
codex ✓ pinged (13.6s)
spark ✓ pinged (12.4s)
codex CLI trigger returned without error (13.6s); turn completion is not verified
spark CLI trigger returned without error (12.4s); turn completion is not verified
```

## 亮点
Expand Down Expand Up @@ -97,6 +97,25 @@ limitping bg logs -f
Claude/Codex 的 token 直接复用官方工具(无需另外登录),遇到 401 会自动刷新。Spark 复用
Codex token。

### Codex/Spark 窗口验证

用量为 0% 时,单次限额 API 响应不一定能判断窗口是否已启动。
以下以五小时窗口的重置时间为例:

| 情况(用量均为 0%) | 10:00 查询 | 10:01 查询 |
| --- | --- | --- |
| 已启动:重置时间固定 | 15:00 | 15:00 |
| 未启动:重置时间向后滑动 | 15:00 | 15:01 |

因此,limitping 会比较至少间隔一分钟的查询结果;证据不足时仍显示未确认。
`ping` 不会等待这一分钟,而是提示稍后运行 `status`;`watch`/`bg` 会自动复查。

发送前的限额检查失败时,手动和自动 ping 都会说明原因并停止,不发送请求。
HTTP 401 仍会尝试重新加载或刷新凭据。只有 watcher 负责重试等待:
认证或权限错误从 30 秒退避至最多一小时,其他读取错误最多十分钟,
并遵守 `Retry-After`。修复访问权限后可重启 watcher 立即重试;
手动 ping 不等待这些退避间隔。

## 安装

`limitping` 是一个自包含的单文件二进制——**普通用户无需安装 Go**。
Expand Down Expand Up @@ -214,9 +233,9 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm
claude → claude --model haiku .
claude ✓ pinged (6.6s)
codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok
codex ✓ pinged (13.6s)
codex CLI trigger returned without error (13.6s); turn completion is not verified
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok
spark ✓ pinged (12.4s)
spark CLI trigger returned without error (12.4s); turn completion is not verified
```

ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/bg.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ func parseBgPingAttempt(line string) (bgPingAttempt, bool) {
status = bgPingFailed
case strings.Contains(msg, "ping sent, new window started"):
status = bgPingSucceeded
case strings.Contains(msg, "ping trigger returned; checking window"):
status = bgPingSucceeded
default:
return bgPingAttempt{}, false
}
Expand Down
96 changes: 69 additions & 27 deletions internal/cli/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ import (
)

type cliText struct {
rootShort string
rootLong string
helpFlag string
usageTemplate string
verifyAlreadyActive string
verifyQuotaStateFmt string
verifyStarted string
verifyNotStarted string
verifyUnknown string
verifyStatusCheck string
verifyRetryFmt string
verifyRunning string
verifyReady string
verifyUnavailable string
verifyDisabled string
verifyNoBaseline string
verifyCheckFmt string
rootShort string
rootLong string
helpFlag string
usageTemplate string

helpCommandShort string
helpCommandLong string
Expand Down Expand Up @@ -56,13 +69,14 @@ type cliText struct {
statusNowWord string
statusWeekdays [7]string // Sunday first; zero value = Go's "Mon" names

pingShort string
pingLong string
pingDryRunFlag string
pingWouldRunFmt string // provider, command
pingSendingFmt string // provider, spinner frame, elapsed
pingFailedFmt string // provider, elapsed, error
pingSuccessFmt string // provider, elapsed, usage suffix
pingShort string
pingLong string
pingDryRunFlag string
pingWouldRunFmt string // provider, command
pingSendingFmt string // provider, spinner frame, elapsed
pingFailedFmt string // provider, elapsed, error
pingSuccessFmt string // provider, elapsed, usage suffix
pingTriggerReturnedFmt string

watchShort string
watchLong string
Expand Down Expand Up @@ -186,9 +200,22 @@ func isChineseLocale() bool {
}

var enText = cliText{
rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back",
rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.",
helpFlag: "help for this command",
verifyAlreadyActive: "already active before ping",
verifyQuotaStateFmt: " %s quota state: %s\n",
verifyStarted: "window started",
verifyNotStarted: "window not started (reset is an estimate)",
verifyUnknown: "window start unconfirmed (reset is an estimate)",
verifyStatusCheck: "Window start unconfirmed; run `limitping status` again in about a minute.",
verifyRetryFmt: "Automatic ping retry eligible at %s.",
verifyRunning: "Ping in progress.",
verifyReady: "Window not started; eligible for automatic ping, subject to watcher checks.",
verifyUnavailable: "Quota-window state unavailable; run `limitping status` again in about a minute.",
verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.",
verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.",
verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n",
rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back",
rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.",
helpFlag: "help for this command",
usageTemplate: `Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
Expand Down Expand Up @@ -272,11 +299,12 @@ Examples:
limitping ping
limitping p claude
limitping ping codex --dry-run`,
pingDryRunFlag: "print the command without sending",
pingWouldRunFmt: "%-7s would run: %s\n",
pingSendingFmt: "\r%-7s %c sending… %s",
pingFailedFmt: "%-7s ✗ failed after %s: %v\n",
pingSuccessFmt: "%-7s ✓ pinged (%s%s)\n",
pingDryRunFlag: "print the command without sending",
pingWouldRunFmt: "%-7s would run: %s\n",
pingSendingFmt: "\r%-7s %c sending… %s",
pingFailedFmt: "%-7s ✗ failed after %s: %v\n",
pingSuccessFmt: "%-7s ✓ pinged (%s%s)\n",
pingTriggerReturnedFmt: "%-7s CLI trigger returned without error (%s%s); turn completion is not verified\n",

watchShort: "Run the foreground daemon and ping each provider when its 5h window resets",
watchLong: `Run the foreground daemon. When a provider's 5h window resets, limitping sends the minimal message to start the next window.
Expand Down Expand Up @@ -444,9 +472,22 @@ Examples:
}

var zhText = cliText{
rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙",
rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。",
helpFlag: "显示此命令的帮助",
verifyAlreadyActive: "ping 前已启动",
verifyQuotaStateFmt: " %s 限额状态:%s\n",
verifyStarted: "窗口已启动",
verifyNotStarted: "窗口未启动(重置时间为估计值)",
verifyUnknown: "窗口启动尚未确认(重置时间为估计值)",
verifyStatusCheck: "窗口启动尚未确认;请约一分钟后再次运行 `limitping status`。",
verifyRetryFmt: "自动 ping 最早可于 %s 重试。",
verifyRunning: "Ping 正在进行。",
verifyReady: "窗口未启动;可尝试自动 ping,仍需通过监视器检查。",
verifyUnavailable: "窗口状态不可用;请约一分钟后再次运行 `limitping status`。",
verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。",
verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。",
verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n",
rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙",
rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。",
helpFlag: "显示此命令的帮助",
usageTemplate: `用法:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
Expand Down Expand Up @@ -532,11 +573,12 @@ var zhText = cliText{
limitping ping
limitping p claude
limitping ping codex --dry-run`,
pingDryRunFlag: "只打印将执行的命令,不真正发送",
pingWouldRunFmt: "%-7s 将执行: %s\n",
pingSendingFmt: "\r%-7s %c 发送中… %s",
pingFailedFmt: "%-7s ✗ 失败 (耗时 %s): %v\n",
pingSuccessFmt: "%-7s ✓ 已 ping (%s%s)\n",
pingDryRunFlag: "只打印将执行的命令,不真正发送",
pingWouldRunFmt: "%-7s 将执行: %s\n",
pingSendingFmt: "\r%-7s %c 发送中… %s",
pingFailedFmt: "%-7s ✗ 失败 (耗时 %s): %v\n",
pingSuccessFmt: "%-7s ✓ 已 ping (%s%s)\n",
pingTriggerReturnedFmt: "%-7s CLI 触发已返回且未报错(%s%s);尚未验证轮次完成\n",

watchShort: "以前台守护方式运行,并在每个 Provider 的 5h 窗口重置时自动 ping",
watchLong: `以前台守护方式运行。某个 Provider 的 5h 窗口重置后,limitping 会发送最小消息来开启下一个窗口。
Expand Down
17 changes: 15 additions & 2 deletions internal/cli/ping.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"sync/atomic"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -72,6 +73,9 @@ func runPing(parent context.Context, out io.Writer, text cliText, p provider.Pro
defer cancel()

start := time.Now()
var stage atomic.Value
stage.Store("")
ctx = provider.WithPingStage(ctx, func(s string) { stage.Store(s) })
type outcome struct {
res *provider.TriggerResult
err error
Expand Down Expand Up @@ -100,18 +104,27 @@ func runPing(parent context.Context, out io.Writer, text cliText, p provider.Pro
report(out, text, name, start, o.res, o.err)
return o.err
case <-ticker.C:
fmt.Fprintf(out, text.pingSendingFmt, name, frames[i%len(frames)], elapsed(start))
label := name
if s := stage.Load().(string); s != "" {
label += " [" + s + "]"
}
fmt.Fprintf(out, text.pingSendingFmt, label, frames[i%len(frames)], elapsed(start))
i++
}
}
}

func report(out io.Writer, text cliText, name string, start time.Time, res *provider.TriggerResult, err error) {
defer reportVerification(out, text, res)
if err != nil {
fmt.Fprintf(out, text.pingFailedFmt, name, elapsed(start), localizedProviderError(text, err))
return
}
fmt.Fprintf(out, text.pingSuccessFmt, name, elapsed(start), usageSuffix(res))
format := text.pingSuccessFmt
if res != nil && res.Verification != nil {
format = text.pingTriggerReturnedFmt
}
fmt.Fprintf(out, format, name, elapsed(start), usageSuffix(res))
}

// usageSuffix renders the token/cost tail, e.g. ", 32,934 tok, $0.0110".
Expand Down
46 changes: 46 additions & 0 deletions internal/cli/ping_quota_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cli

import (
"bytes"
"fmt"
"strings"
"testing"
"time"

"github.com/wavever/CCLimitPing/internal/provider"
"github.com/wavever/CCLimitPing/internal/usage"
)

func TestPingAlreadyActiveBeforePing(t *testing.T) {
for _, target := range []string{"weekly", "five_hour"} {
for _, before := range []string{"", "unknown", "not_started", "started"} {
for _, after := range []string{"unknown", "not_started", "started"} {
for _, failed := range []bool{false, true} {
res := &provider.TriggerResult{StatusEnabled: true,
Verification: &usage.Verification{Target: target,
FiveHour: usage.StartStatus{State: after}, Weekly: usage.StartStatus{State: after}},
}
if before != "" {
res.PreVerification = &usage.Verification{Target: target,
FiveHour: usage.StartStatus{State: before}, Weekly: usage.StartStatus{State: before}}
}
var err error
if failed {
err = fmt.Errorf("CLI failed")
}
for _, text := range []cliText{enText, zhText} {
var out bytes.Buffer
report(&out, text, "codex", time.Now(), res, err)
want := before == "started" && after == "started"
if strings.Contains(out.String(), text.verifyAlreadyActive) != want {
t.Fatalf("%s/%s/%s/failed=%v: %s", target, before, after, failed, out.String())
}
if want && !strings.Contains(out.String(), fmt.Sprintf(text.verifyQuotaStateFmt, target, text.verifyAlreadyActive)) {
t.Fatal(out.String())
}
}
}
}
}
}
}
Loading