diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef81f3..0972b7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/README.md b/README.md index f9b6bbe..fe30d61 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 = "" diff --git a/README.zh-CN.md b/README.zh-CN.md index c64cf41..243592d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` 示例: @@ -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 = "" diff --git a/internal/config/config.go b/internal/config/config.go index 3cc622c..3756795 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", }, @@ -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" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7fb670d..d07dd5e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 7fb0c45..5190ee8 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -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. @@ -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" @@ -563,6 +576,13 @@ 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 { @@ -570,6 +590,9 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( } 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) @@ -577,8 +600,9 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( 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) @@ -586,41 +610,55 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( 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; ; { diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index e36a1d6..97acfe6 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" "time" @@ -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", @@ -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) } @@ -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", @@ -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) }