Skip to content
Closed
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[![CI](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml/badge.svg)](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/wavever/CCLimitPing?include_prereleases&sort=semver)](https://github.com/wavever/CCLimitPing/releases)
![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)

Start the next **Claude Code**, **Codex**, or **Spark** rate-limit window the
moment the previous one resets.
Expand Down Expand Up @@ -154,6 +154,12 @@ tar -xzf limitping_darwin_arm64.tar.gz
sudo mv limitping /usr/local/bin/
```

**Windows** — use the manual download above (`limitping_windows_amd64.zip` or
`limitping_windows_arm64.zip`). The Claude Code and Codex interactive triggers
run natively through a ConPTY pseudo-console, including CLIs installed as
`.cmd`/`.bat` shims. Not every command is available on Windows yet —
`limitping continue` still reports that it is unsupported.

**Homebrew** (macOS / Linux) — `brew install wavever/tap/limitping`
_(works once the Homebrew tap is set up — see `.goreleaser.yaml`)._

Expand Down
7 changes: 6 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[![CI](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml/badge.svg)](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/wavever/CCLimitPing?include_prereleases&sort=semver)](https://github.com/wavever/CCLimitPing/releases)
![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)

在上一个窗口重置的瞬间,立即启动下一个 **Claude Code** / **Codex** / **Spark** 限额窗口。

Expand Down Expand Up @@ -138,6 +138,11 @@ tar -xzf limitping_darwin_arm64.tar.gz
sudo mv limitping /usr/local/bin/
```

**Windows** —— 使用上面的手动下载(`limitping_windows_amd64.zip` 或
`limitping_windows_arm64.zip`)。Claude Code 与 Codex 的交互式触发
通过原生 ConPTY 伪控制台运行,也支持以 `.cmd`/`.bat` shim 安装的 CLI。
Windows 上并非所有命令都已可用 —— `limitping continue` 仍会提示暂不支持。

**Homebrew**(macOS / Linux)—— `brew install wavever/tap/limitping`
_(配好 Homebrew tap 后可用;见 `.goreleaser.yaml`)。_

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.46.0 // indirect
)
43 changes: 18 additions & 25 deletions internal/provider/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,16 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"

"github.com/creack/pty"

"github.com/wavever/CCLimitPing/internal/activity"
"github.com/wavever/CCLimitPing/internal/auth"
"github.com/wavever/CCLimitPing/internal/config"
"github.com/wavever/CCLimitPing/internal/terminal"
"github.com/wavever/CCLimitPing/internal/usage"
)

Expand Down Expand Up @@ -272,58 +270,55 @@ func (c *Claude) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, erro
return res, nil
}

cmd := exec.CommandContext(ctx, "claude", args...)
ptmx, err := pty.Start(cmd)
sess, err := terminal.Start(ctx, "claude", args)
if err != nil {
return res, fmt.Errorf("claude interactive failed to start: %w", err)
}
defer ptmx.Close()
defer sess.Close()

output := &limitedBuffer{limit: 4096}
go func() {
_, _ = io.Copy(output, ptmx)
_, _ = io.Copy(output, sess)
}()

done := make(chan error, 1)
go func() {
done <- cmd.Wait()
done <- sess.Wait()
}()

// Phase 1: wait for the TUI to render and settle so the submit Enter lands on
// a ready prompt holding the prefilled message.
if terminal, err := claudeAwait(ctx, cmd, ptmx, output, done, claudeStartupTimeout,
func(idle, _ time.Duration) bool { return idle >= claudeStartupSettle }); terminal {
if term, err := claudeAwait(ctx, sess, output, done, claudeStartupTimeout,
func(idle, _ time.Duration) bool { return idle >= claudeStartupSettle }); term {
return res, err
}

// Submit the prefilled prompt. This is the model request that anchors the 5h
// window; the previous implementation never sent it, so the window never
// started even though the session exited cleanly.
if _, werr := ptmx.Write([]byte("\r")); werr != nil {
if _, werr := sess.Write([]byte("\r")); werr != nil {
return res, fmt.Errorf("claude interactive failed to submit prompt: %w: %s", werr, truncate(output.Bytes(), 300))
}

// Phase 2: let the turn run until its output goes quiet (bounded by a floor
// and a hard cap), so we don't cancel the in-flight request by exiting early.
if terminal, err := claudeAwait(ctx, cmd, ptmx, output, done, claudeTurnMaxWait,
if term, err := claudeAwait(ctx, sess, output, done, claudeTurnMaxWait,
func(idle, elapsed time.Duration) bool {
return elapsed >= claudeTurnMinWait && idle >= claudeTurnQuiet
}); terminal {
}); term {
return res, err
}

// Phase 3: quit. The window is already anchored, so a messy shutdown here
// must not fail the ping.
_, _ = ptmx.Write([]byte("/exit\r"))
_, _ = sess.Write([]byte("/exit\r"))
select {
case err := <-done:
return res, claudeInteractiveErr(err, output)
case <-ctx.Done():
return res, claudeInteractiveCancel(ctx, cmd, ptmx, done, output)
return res, claudeInteractiveCancel(ctx, sess, done, output)
case <-time.After(claudeExitGrace):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = sess.Kill()
select {
case <-done:
case <-time.After(time.Second):
Expand All @@ -340,7 +335,7 @@ func (c *Claude) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, erro
// PTY output and elapsed is the time since this phase began. It returns
// terminal=true (with an error to propagate) only if the process exits or ctx is
// cancelled first; otherwise terminal=false and the caller continues.
func claudeAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) {
func claudeAwait(ctx context.Context, sess terminal.Session, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) {
start := time.Now()
deadline := time.After(maxWait)
ticker := time.NewTicker(claudePollInterval)
Expand All @@ -350,7 +345,7 @@ func claudeAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limi
case err := <-done:
return true, claudeInteractiveErr(err, output)
case <-ctx.Done():
return true, claudeInteractiveCancel(ctx, cmd, ptmx, done, output)
return true, claudeInteractiveCancel(ctx, sess, done, output)
case <-deadline:
return false, nil
case <-ticker.C:
Expand Down Expand Up @@ -386,11 +381,9 @@ func claudeSubscriptionErrorFromOutput(raw []byte) error {
return nil
}

func claudeInteractiveCancel(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = ptmx.Close()
func claudeInteractiveCancel(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error {
_ = sess.Kill()
_ = sess.Close()
select {
case <-done:
case <-time.After(time.Second):
Expand Down
42 changes: 18 additions & 24 deletions internal/provider/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"unicode"

"github.com/BurntSushi/toml"
"github.com/creack/pty"

"github.com/wavever/CCLimitPing/internal/activity"
"github.com/wavever/CCLimitPing/internal/auth"
"github.com/wavever/CCLimitPing/internal/config"
"github.com/wavever/CCLimitPing/internal/terminal"
"github.com/wavever/CCLimitPing/internal/usage"
)

Expand Down Expand Up @@ -569,34 +568,33 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (
return res, nil
}

cmd := exec.CommandContext(ctx, "codex", args...)
ptmx, err := pty.Start(cmd)
sess, err := terminal.Start(ctx, "codex", args)
if err != nil {
return res, fmt.Errorf("codex interactive failed to start: %w", err)
}
defer ptmx.Close()
defer sess.Close()

output := &limitedBuffer{limit: 4096}
go func() {
_, _ = io.Copy(output, ptmx)
_, _ = io.Copy(output, sess)
}()

done := make(chan error, 1)
go func() {
done <- cmd.Wait()
done <- sess.Wait()
}()

if terminal, err := codexAwait(ctx, cmd, ptmx, output, done, codexTurnMaxWait,
if term, err := codexAwait(ctx, sess, output, done, codexTurnMaxWait,
func(idle, elapsed time.Duration) bool {
return elapsed >= codexTurnMinWait && idle >= codexTurnQuiet
}); terminal {
}); term {
return res, err
}

return res, codexInteractiveStop(ctx, cmd, ptmx, done, output)
return res, codexInteractiveStop(ctx, sess, done, output)
}

func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) {
func codexAwait(ctx context.Context, sess terminal.Session, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) {
start := time.Now()
deadline := time.After(maxWait)
ticker := time.NewTicker(codexPollInterval)
Expand All @@ -606,7 +604,7 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit
case err := <-done:
return true, codexInteractiveErr(err, output)
case <-ctx.Done():
return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output)
return true, codexInteractiveCancel(ctx, sess, done, output)
case <-deadline:
return false, nil
case <-ticker.C:
Expand All @@ -618,27 +616,25 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit
}
}

func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error {
func codexInteractiveStop(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error {
deadline := time.After(codexExitGrace)
ticker := time.NewTicker(codexExitGrace / 2)
defer ticker.Stop()

for sent := false; ; {
if !sent {
_, _ = ptmx.Write([]byte{0x03})
_, _ = sess.Write([]byte{0x03})
sent = true
}
select {
case <-done:
return nil
case <-ctx.Done():
return codexInteractiveCancel(ctx, cmd, ptmx, done, output)
return codexInteractiveCancel(ctx, sess, done, output)
case <-ticker.C:
_, _ = ptmx.Write([]byte{0x03})
_, _ = sess.Write([]byte{0x03})
case <-deadline:
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = sess.Kill()
select {
case <-done:
case <-time.After(time.Second):
Expand All @@ -659,11 +655,9 @@ func codexInteractiveErr(err error, output *limitedBuffer) error {
return fmt.Errorf("codex interactive failed: %w: %s", err, tail)
}

func codexInteractiveCancel(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = ptmx.Close()
func codexInteractiveCancel(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error {
_ = sess.Kill()
_ = sess.Close()
select {
case <-done:
case <-time.After(time.Second):
Expand Down
Loading