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

jobs:
codex-pty-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Detect Codex completion notification through a PTY
run: go test -race -count=1 -v ./internal/provider -run '^TestCodexTriggerWaitsForTurnCompleteNotification$'

build:
runs-on: ubuntu-latest
steps:
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,16 @@ 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)
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok
spark ✓ pinged (12.4s)
codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok
codex ✓ pinged (6.8s)
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok
spark ✓ pinged (6.5s)
```

For Codex/Spark, `limitping` automatically appends the `-c tui...` flags to
enable turn-completion notifications and stop the TUI when one is received.
A 45-second safety timeout remains; this does not verify quota-window activation.

Use `status` or `bg status` for the authoritative 5h/weekly window view after a
ping.

Expand Down Expand Up @@ -328,7 +332,7 @@ continue_prompt = "continue" # message `continue` injects on 5h recovery; empty
[codex]
enabled = true
prompt = "ok"
model = "gpt-5.4-mini" # cheapest Codex model for triggering
model = "gpt-5.6-luna" # cheapest Codex model for triggering
reasoning_effort = "low" # "minimal" is rejected when web_search/image_gen tools are enabled
extra_args = [] # extra Codex CLI args; exec-only flags such as --json are ignored
align_start = ""
Expand Down
13 changes: 8 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,15 @@ 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)
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok
spark ✓ pinged (12.4s)
codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok
codex ✓ pinged (6.8s)
spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok
spark ✓ pinged (6.5s)
```

对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数,收到轮次完成通知后停止 TUI。
仍保留 45 秒安全超时;这不代表已确认限额窗口启动。

ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。

`status` 示例:
Expand Down Expand Up @@ -309,7 +312,7 @@ continue_prompt = "continue" # continue 在 5h 恢复时注入的消息;留空
[codex]
enabled = true
prompt = "ok"
model = "gpt-5.4-mini" # 用于触发的最便宜 Codex 模型
model = "gpt-5.6-luna" # 用于触发的最便宜 Codex 模型
reasoning_effort = "low" # 启用 web_search/image_gen 工具时,"minimal" 会被拒绝
extra_args = [] # 额外 Codex CLI 参数;--json 等 exec-only 参数会被忽略
align_start = ""
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func Default() Config {
Codex: ProviderConfig{
Enabled: true,
Prompt: "ok",
Model: "gpt-5.4-mini",
Model: "gpt-5.6-luna",
ReasoningEffort: "low",
ContinuePrompt: "continue",
},
Expand Down Expand Up @@ -209,7 +209,7 @@ enabled = true
prompt = "ok"
# Cheapest Codex model for triggering (see ~/.codex/models_cache.json for the
# list available to your plan). Empty = use the Codex default model.
model = "gpt-5.4-mini"
model = "gpt-5.6-luna"
# "low" keeps the ping cheap; "minimal" is rejected when web_search/image_gen
# tools are enabled in your Codex config.
reasoning_effort = "low"
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ enabled = false
t.Fatal("claude.enabled = true, want the file's false to win")
}
// Untouched fields keep their defaults.
if cfg.Claude.Model != "haiku" || cfg.Codex.Model != "gpt-5.4-mini" || cfg.UsageDisplay != "used" {
if cfg.Claude.Model != "haiku" || cfg.Codex.Model != "gpt-5.6-luna" || cfg.UsageDisplay != "used" {
t.Fatalf("defaults not preserved: claude.model=%q codex.model=%q usage_display=%q",
cfg.Claude.Model, cfg.Codex.Model, cfg.UsageDisplay)
}
Expand Down
104 changes: 71 additions & 33 deletions internal/provider/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,27 @@ const (
codexAPIPath = "/api/codex/usage"
codexUserAgent = "limitping"
sparkDefaultModel = "gpt-5.3-codex-spark"
codexTurnComplete = "\x1b]9;"
codexTurnScanLimit = 4096

// codexRedeemCooldown throttles the automatic redemption path so a
// once-a-minute poll loop cannot re-attempt a refused redemption every cycle.
codexRedeemCooldown = 15 * time.Minute

codexTurnMinWait = 4 * time.Second
codexTurnQuiet = 2500 * time.Millisecond
codexTurnMaxWait = 45 * time.Second
codexExitGrace = 5 * time.Second
codexPollInterval = 200 * time.Millisecond
codexTurnMaxWait = 45 * time.Second
codexExitGrace = 5 * time.Second
)

type codexInteractiveTiming struct {
maxWait time.Duration
exitGrace time.Duration
}

var defaultCodexInteractiveTiming = codexInteractiveTiming{
maxWait: codexTurnMaxWait,
exitGrace: codexExitGrace,
}

// Codex reads usage via the ChatGPT backend usage endpoint and triggers windows
// via the interactive, TTY-backed Codex CLI. Headless `codex exec` can consume
// tokens without anchoring the subscription-backed Codex window.
Expand Down Expand Up @@ -551,6 +560,10 @@ func codexWindowToUsage(w codexWindow) usage.Window {
}

func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (*TriggerResult, error) {
return triggerCodexWithTiming(ctx, cfg, dryRun, defaultCodexInteractiveTiming)
}

func triggerCodexWithTiming(ctx context.Context, cfg config.ProviderConfig, dryRun bool, timing codexInteractiveTiming) (*TriggerResult, error) {
prompt := cfg.Prompt
if prompt == "" {
prompt = "ok"
Expand All @@ -563,64 +576,89 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (
args = append(args, "-m", cfg.Model)
}
args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...)
// A PTY is always treated as focused, so force Codex's turn-complete OSC 9
// notification and use it as the exact boundary before stopping the TUI.
args = append(args,
"-c", `tui.notifications=["agent-turn-complete"]`,
"-c", `tui.notification_method="osc9"`,
"-c", `tui.notification_condition="always"`,
)
args = append(args, prompt)
res := &TriggerResult{Command: "codex " + shellJoin(args)}
if dryRun {
return res, nil
}

cmd := exec.CommandContext(ctx, "codex", args...)
if term := os.Getenv("TERM"); term == "" || term == "dumb" {
cmd.Env = append(cmd.Environ(), "TERM=xterm-256color")
}
ptmx, err := pty.Start(cmd)
if err != nil {
return res, fmt.Errorf("codex interactive failed to start: %w", err)
}
defer ptmx.Close()

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

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

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

return res, codexInteractiveStop(ctx, cmd, ptmx, done, output)
return res, codexInteractiveStop(ctx, cmd, ptmx, done, output, timing.exitGrace)
}

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) {
start := time.Now()
deadline := time.After(maxWait)
ticker := time.NewTicker(codexPollInterval)
defer ticker.Stop()
for {
select {
case err := <-done:
return true, codexInteractiveErr(err, output)
case <-ctx.Done():
return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output)
case <-deadline:
return false, nil
case <-ticker.C:
changed := output.changedAt()
if !changed.IsZero() && ready(time.Since(changed), time.Since(start)) {
return false, nil
}
}
type codexTurnMarkers struct {
buf []byte
finished bool
completed chan struct{}
}

func newCodexTurnMarkers() *codexTurnMarkers {
return &codexTurnMarkers{completed: make(chan struct{})}
}

func (m *codexTurnMarkers) Write(p []byte) (int, error) {
if m.finished {
return len(p), nil
}
m.buf = append(m.buf, p...)
if start := bytes.Index(m.buf, []byte(codexTurnComplete)); start >= 0 && bytes.IndexByte(m.buf[start:], 0x07) >= 0 {
close(m.completed)
m.finished = true
m.buf = nil
}
if len(m.buf) > codexTurnScanLimit {
m.buf = append(m.buf[:0], m.buf[len(m.buf)-codexTurnScanLimit:]...)
}
return len(p), nil
}

func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, completed <-chan struct{}, done <-chan error, maxWait time.Duration) (bool, error) {
select {
case <-completed:
return false, nil
case err := <-done:
return true, codexInteractiveErr(err, output)
case <-ctx.Done():
return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output)
case <-time.After(maxWait):
return false, nil
}
}

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

for sent := false; ; {
Expand Down
82 changes: 79 additions & 3 deletions internal/provider/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -307,7 +308,7 @@ chatgpt_base_url = "https://api.openai.com"
func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) {
c := NewCodex(config.ProviderConfig{
Prompt: "ok",
Model: "gpt-5.4-mini",
Model: "gpt-5.6-luna",
ReasoningEffort: "low",
ExtraArgs: []string{
"--skip-git-repo-check",
Expand All @@ -322,7 +323,7 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) {
if err != nil {
t.Fatalf("dry-run trigger: %v", err)
}
want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only ok"
want := "codex -c model_reasoning_effort=low -m gpt-5.6-luna --search --sandbox read-only -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok"
if res.Command != want {
t.Fatalf("command = %q, want %q", res.Command, want)
}
Expand All @@ -331,6 +332,81 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) {
}
}

func TestCodexTriggerWaitsForTurnCompleteNotification(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires Unix PTY support")
}
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
turnPath := filepath.Join(dir, "turn")
termPath := filepath.Join(dir, "term")
script := `#!/bin/sh
printf '%s\n' "$@" > "$CODEX_TEST_ARGS"
printf '%s' "$TERM" > "$CODEX_TEST_TERM"
printf 'startup screen\n'
sleep 0.08
printf 'submitted' > "$CODEX_TEST_TURN"
i=0
while [ "$i" -lt 20 ]; do
printf '.'
sleep 0.01
i=$((i + 1))
done
printf '\033]9;turn finished\007'
trap 'exit 0' INT TERM
while :; do
printf '.'
sleep 0.01
done
`
if err := os.WriteFile(filepath.Join(dir, "codex"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("CODEX_TEST_ARGS", argsPath)
t.Setenv("CODEX_TEST_TURN", turnPath)
t.Setenv("CODEX_TEST_TERM", termPath)
t.Setenv("TERM", "dumb")

timing := codexInteractiveTiming{
maxWait: 10 * time.Second,
exitGrace: 50 * time.Millisecond,
}
started := time.Now()
_, err := triggerCodexWithTiming(context.Background(), config.ProviderConfig{
Prompt: "ping through pty",
Model: "test-model",
}, false, timing)
if err != nil {
t.Fatalf("trigger: %v", err)
}
if elapsed := time.Since(started); elapsed >= 5*time.Second {
t.Fatalf("trigger took %s, want completion marker to stop it before fallback", elapsed)
}

args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(args), "ping through pty") {
t.Fatalf("arguments = %q, want positional prompt", args)
}
turn, err := os.ReadFile(turnPath)
if err != nil {
t.Fatal(err)
}
if string(turn) != "submitted" {
t.Fatalf("turn marker = %q, want submitted", turn)
}
term, err := os.ReadFile(termPath)
if err != nil {
t.Fatal(err)
}
if string(term) != "xterm-256color" {
t.Fatalf("TERM = %q, want xterm-256color", term)
}
}

func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) {
c := NewSpark(config.ProviderConfig{
Prompt: "ok",
Expand All @@ -345,7 +421,7 @@ func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) {
if err != nil {
t.Fatalf("dry-run trigger: %v", err)
}
want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok"
want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok"
if res.Command != want {
t.Fatalf("command = %q, want %q", res.Command, want)
}
Expand Down