diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b78a8..8547912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +### Added +- Coding-plan usage-window tracking for the subscription orchestration backends + (Claude Code, Codex, and OpenCode + Z.AI GLM). qmax-code now tracks the plan's + rolling 5-hour limit — when it opens, how many turns/tokens it has used, and + when it resets — and surfaces it in the input status bar and a new `/plan` + command (also summarized in `/status` and `/cost`). The window length is + configurable via `plan_window_hours` (default 5). +- OpenCode and Codex backends now report per-turn token usage (previously only + Claude Code did), feeding both the session cost totals and the plan window. + +### Fixed +- OpenCode error events (`{"type":"error", ...}`) were parsed and surfaced + instead of being silently dropped. In particular, a `429` / usage-limit + refusal — the exact signal that the coding-plan window is full — now prints a + clear "coding-plan limit reached" message and marks the window exhausted, + reading the provider's reset time from rate-limit headers when present. + Provider errors carrying an HTTP status code (auth, quota, 5xx) are shown; + benign status-code-less events that opencode 1.0.x emits even on successful + turns are suppressed unless output is verbose, so they don't become noise. + ## [1.22.0] - 2026-07-24 ### Added diff --git a/README.md b/README.md index 4e54acb..93d01c8 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ agent. qmax-code declares that dependency in the Codex skill metadata. /queue Add follow-up work; typing during a turn also queues it /theme Preview and select a terminal theme /cost Show token usage and estimated cost +/plan Show coding-plan usage window (5h limit + reset time) /config Show session configuration /help Show the full in-app command reference ``` diff --git a/config_command.go b/config_command.go index 113aeeb..9e0af81 100644 --- a/config_command.go +++ b/config_command.go @@ -35,6 +35,7 @@ import ( // auto_save → bool // output_verbose → bool (compact vs previous detailed answer style) // max_token_budget → integer +// plan_window_hours → integer (subscription plan rolling window; 0 = 5h default) func handleConfigCommand(args []string) { if len(args) == 0 || args[0] == "show" { printConfig() @@ -90,6 +91,12 @@ func printConfig() { fmt.Printf(" auto_save = %t\n", cfg.AutoSave) fmt.Printf(" output_verbose = %t\n", cfg.OutputVerbose) fmt.Printf(" max_token_budget = %d\n", cfg.MaxTokenBudget) + planWindow := cfg.PlanWindowHours + if planWindow == 0 { + fmt.Printf(" plan_window_hours = %d (default)\n", api.DefaultPlanWindowHours) + } else { + fmt.Printf(" plan_window_hours = %d\n", planWindow) + } if cfg.AnthropicKey != "" { fmt.Println(" anthropic_key = (set; stored in OS keychain)") } else { @@ -244,6 +251,20 @@ func setConfigField(key, value string) error { cfg.MaxTokenBudget = n } + case "plan_window_hours", "plan-window-hours": + if value == "" { + cfg.PlanWindowHours = 0 + } else { + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("plan_window_hours must be an integer, got %q", value) + } + if n < 0 { + return fmt.Errorf("plan_window_hours must be non-negative, got %d", n) + } + cfg.PlanWindowHours = n + } + case "ollama_url": cfg.OllamaURL = value diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 5c01fac..04110ea 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -94,6 +94,7 @@ Supported keys: | `auto_save` | Boolean | | `output_verbose` | Boolean; compact vs. detailed CLI-backend answers | | `max_token_budget` | Integer token budget | +| `plan_window_hours` | Integer; subscription coding-plan rolling window length (0 or unset = 5-hour default) | | `ollama_url` | HTTP(S) Ollama endpoint | | `ollama_model` | Ollama chat/full-agent model | | `ollama_agent_model` | Optional heavier Ollama agent model | @@ -155,6 +156,7 @@ behavior. | `/context` | Show current session context. | | `/status` | Show connection, session, usage, and model information. | | `/cost` | Show token usage and estimated model cost. | +| `/plan` | Show the subscription coding-plan usage window: elapsed, turns, and time until the rolling 5-hour limit resets (cc/codex/opencode backends). | In standalone mode, `/connect` and `/project` explain how to return to connected mode. `/status`, `/context`, and `/config` identify the active standalone diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index 139a993..4b05359 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -37,6 +37,16 @@ type TurnStatsProvider interface { LastTurnStats() (inputTokens, outputTokens int, ok bool) } +// PlanLimitReporter is optionally implemented by CLI agents that can detect the +// subscription plan's usage limit being hit on the most recent Run — e.g. a +// 429 / "usage limit reached" event in the backend's stream. The REPL marks the +// coding-plan window exhausted so /plan and the status bar reflect the real +// stop. reset is the provider-reported reset time when the error carried one +// (Retry-After / rate-limit header), otherwise the zero value. +type PlanLimitReporter interface { + LastPlanLimit() (reset time.Time, hit bool) +} + // CCAgent orchestrates a Claude Code CLI subprocess for LLM inference. // Inference runs through the user's Claude Code login, so qmax-code does not // need a QM-held Anthropic API key. Because this agent uses `claude --print`, diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index 0c501f8..8494227 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -40,6 +40,11 @@ type CodexAgent struct { permissionMode string // "standard" (Codex prompts per-action) | "unattended" (--dangerously-bypass-approvals-and-sandbox) sctx *api.SessionContext history []codexTurn // conversation history managed on our side + lastTurnIn int // token usage of the most recent turn, when codex reports it + lastTurnOut int + lastTurnOK bool + lastLimitHit bool // true if the plan limit was hit this turn + lastLimitReset time.Time // provider-reported reset (usually zero for codex) mu sync.Mutex runMu sync.Mutex runCancel context.CancelFunc // non-nil while Run() is active @@ -150,6 +155,11 @@ func (a *CodexAgent) Run(userMsg string, term *tui.Terminal) (string, error) { return "", fmt.Errorf("MCP config: %w", err) } + a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = 0, 0, false + a.lastLimitHit, a.lastLimitReset = false, time.Time{} + a.mu.Unlock() + // Build the full prompt: QA system prompt + conversation history + current message. prompt := a.buildPrompt(userMsg) @@ -193,6 +203,7 @@ func (a *CodexAgent) Run(userMsg string, term *tui.Terminal) (string, error) { for scanner.Scan() { line := scanner.Text() + a.inspectCodexEvent(line, term) text := extractCodexMessage(line) if text != "" { term.StreamText(text + "\n") @@ -287,6 +298,79 @@ func (a *CodexAgent) Cancel() { // Cleanup is a no-op for CodexAgent (no temp files to remove). func (a *CodexAgent) Cleanup() {} +// codexEvent is a tolerant view of a `codex exec --json` event line, capturing +// the token-usage and error fields qmax-code needs for the coding-plan window. +// Field names are best-effort across codex versions (confirm against a real +// run); the time-based window works even when none are populated. +type codexEvent struct { + Type string `json:"type"` + Message string `json:"message"` + Error string `json:"error"` + + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + Input int `json:"input"` + Output int `json:"output"` + } `json:"usage"` +} + +// inspectCodexEvent folds token usage from a codex event line into the turn +// stats and surfaces plan-limit errors that would otherwise be invisible (codex +// prints them to its JSON stream, not always to stderr). +func (a *CodexAgent) inspectCodexEvent(line string, term *tui.Terminal) { + var ev codexEvent + if json.Unmarshal([]byte(line), &ev) != nil { + return + } + + in, out := ev.InputTokens, ev.OutputTokens + if ev.Usage != nil { + if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 { + in, out = ev.Usage.InputTokens, ev.Usage.OutputTokens + } else if ev.Usage.Input > 0 || ev.Usage.Output > 0 { + in, out = ev.Usage.Input, ev.Usage.Output + } + } + if in > 0 || out > 0 { + a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = in, out, true + a.mu.Unlock() + } + + if strings.Contains(ev.Type, "error") || ev.Error != "" { + msg := ev.Error + if msg == "" { + msg = ev.Message + } + if isPlanLimitMessage(msg) { + a.mu.Lock() + a.lastLimitHit = true + a.mu.Unlock() + term.PrintError("Coding-plan limit reached — " + msg + " (see /plan)") + } + } +} + +// LastTurnStats returns codex's token usage for the most recent turn, when its +// stream carried any. Satisfies TurnStatsProvider. +func (a *CodexAgent) LastTurnStats() (inputTokens, outputTokens int, ok bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastTurnIn, a.lastTurnOut, a.lastTurnOK +} + +// LastPlanLimit reports whether the plan limit was hit on the most recent turn. +// codex errors carry no reset header, so the reset time is the zero value and +// the window falls back to its time estimate. Satisfies PlanLimitReporter. +func (a *CodexAgent) LastPlanLimit() (reset time.Time, hit bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastLimitReset, a.lastLimitHit +} + // extractCodexMessage parses a JSONL line from `codex exec --json` and returns // the assistant text if the event is an item.completed with type "agent_message". // Returns "" for all other event types (turn lifecycle, tool calls, etc.). diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 7c6658b..07035a0 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "time" @@ -43,6 +44,11 @@ type OpenCodeAgent struct { cfg *api.Config sctx *api.SessionContext lastToolName string + lastTurnIn int // token usage of the most recent turn (from opencode's stream) + lastTurnOut int + lastTurnOK bool // true once a usage event carried tokens this turn + lastLimitHit bool // true if the plan limit was hit this turn + lastLimitReset time.Time // provider-reported reset time, zero if unknown mu sync.Mutex runMu sync.Mutex runCancel context.CancelFunc // non-nil while Run() is active @@ -66,6 +72,32 @@ func FindOpenCode() string { return "" } +// autoFlag caches the one-time probe for `opencode run --auto` support so we +// don't shell out to `--help` on every turn. +var ( + autoFlagOnce sync.Once + autoFlagSupported bool +) + +// openCodeSupportsAutoFlag reports whether the installed opencode accepts the +// `run --auto` flag. Older opencode used --auto to auto-approve tool calls in +// non-interactive `run` mode; opencode 1.x removed it and governs approvals +// through the config `permission` block instead. Passing --auto to a version +// that no longer knows it makes opencode print usage and exit 1 with no output, +// so probe `run --help` once and only pass the flag when it is advertised. +func openCodeSupportsAutoFlag(bin string) bool { + if bin == "" { + return false + } + autoFlagOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, bin, "run", "--help").CombinedOutput() + autoFlagSupported = strings.Contains(string(out), "--auto") + }) + return autoFlagSupported +} + // NewOpenCodeAgent creates an opencode subprocess orchestrator. // modelID is the full "provider/model" string selected via the picker. // effort is "low" | "medium" | "high" (empty defaults to "high"). @@ -122,6 +154,8 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) } a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = 0, 0, false + a.lastLimitHit, a.lastLimitReset = false, time.Time{} sessionID := a.sessionID a.mu.Unlock() @@ -139,16 +173,30 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) } // --auto auto-approves anything not explicitly denied. In standard mode the // managed config denies edits + destructive shell (openCodeStandardPermission), - // so --auto is safe there too; unattended has no denies (full autonomy). Both - // need --auto because `opencode run` is non-interactive — without it, tools - // that would prompt simply block. - args = append(args, "--auto") + // so --auto is safe there too; unattended has no denies (full autonomy). + // Older opencode needed --auto because `opencode run` is non-interactive — + // without it, tools that would prompt simply block. Newer opencode (1.x) + // REMOVED --auto and governs approvals purely through the config `permission` + // block; passing --auto there makes opencode print usage and exit 1 with no + // output. So only add it when the installed opencode still advertises it. + if openCodeSupportsAutoFlag(a.openCodeBin) { + args = append(args, "--auto") + } if sessionID != "" && validOpenCodeSessionID(sessionID) { args = append(args, "--session", sessionID) } // "--" terminates flag parsing so a message starting with "-" is treated as - // the positional prompt rather than an unknown flag. - args = append(args, "--", message) + // the positional prompt rather than an unknown flag. On Windows, opencode is + // typically an npm ".cmd" shim; Go's os/exec routes it through cmd.exe, which + // swallows the "--" separator and drops the positional message after it + // ("You must provide a message or a command"). There we pass the message with + // no "--" — sanitizeCCUserPrompt already stripped control bytes, and a lone + // positional is taken as the message. + if runtime.GOOS == "windows" { + args = append(args, message) + } else { + args = append(args, "--", message) + } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() @@ -195,16 +243,73 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) // --- NDJSON stream parsing --- type ocEvent struct { - Type string `json:"type"` - SessionID string `json:"sessionID"` - Part ocPart `json:"part"` + Type string `json:"type"` + Timestamp int64 `json:"timestamp"` + SessionID string `json:"sessionID"` + Part ocPart `json:"part"` + Error *ocError `json:"error,omitempty"` + // Token usage may appear at the top level of a completion/step event or on + // the message part; opencode/provider field names vary by version, so both + // the "tokens" and "usage" shapes are captured and whichever is populated + // wins. (Confirm exact names against a successful `opencode run --format + // json` sample; the time-based window works regardless of these.) + Tokens *ocTokens `json:"tokens,omitempty"` + Usage *ocTokens `json:"usage,omitempty"` } type ocPart struct { - ID string `json:"id"` - Type string `json:"type"` - Text string `json:"text"` - Tool string `json:"tool"` + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text"` + Tool string `json:"tool"` + Tokens *ocTokens `json:"tokens,omitempty"` + Usage *ocTokens `json:"usage,omitempty"` +} + +// ocTokens tolerates the common token-count field names emitted by opencode and +// its providers (input/output vs prompt/completion). +type ocTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Prompt int `json:"prompt"` + Completion int `json:"completion"` +} + +func (t *ocTokens) in() int { + if t == nil { + return 0 + } + if t.Input > 0 { + return t.Input + } + return t.Prompt +} + +func (t *ocTokens) out() int { + if t == nil { + return 0 + } + if t.Output > 0 { + return t.Output + } + return t.Completion +} + +// ocError is the payload of a `{"type":"error", ...}` event. opencode emits +// these when the provider refuses a turn — a retired model, an auth failure, +// or (the case that matters for plan tracking) a 429 when the coding-plan usage +// limit is reached. Before this was parsed, such events fell through the stream +// switch and the turn ended with nothing shown to the user. +type ocError struct { + Name string `json:"name"` + Data ocErrorData `json:"data"` +} + +type ocErrorData struct { + Message string `json:"message"` + StatusCode int `json:"statusCode"` + ResponseHeaders map[string]string `json:"responseHeaders"` + ResponseBody string `json:"responseBody"` } // parseStream reads opencode's NDJSON output, renders it, captures the session @@ -233,6 +338,21 @@ func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) a.mu.Unlock() } + // Surface provider errors that would otherwise be silently dropped, + // and detect a coding-plan limit hit for the usage-window tracker. + if ev.Error != nil { + a.handleOCError(ev.Error, term) + } + + // Capture token usage from whichever shape/location carried it. + for _, tk := range []*ocTokens{ev.Tokens, ev.Usage, ev.Part.Tokens, ev.Part.Usage} { + if in, out := tk.in(), tk.out(); in > 0 || out > 0 { + a.mu.Lock() + a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = in, out, true + a.mu.Unlock() + } + } + switch { case ev.Type == "text" || ev.Part.Type == "text": id := ev.Part.ID @@ -280,6 +400,53 @@ func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) return finalResult } +// handleOCError renders an opencode error event and, when it signals the +// subscription plan's usage limit, records the hit (and any reset time) so the +// REPL can mark the coding-plan window exhausted. +func (a *OpenCodeAgent) handleOCError(e *ocError, term *tui.Terminal) { + msg := strings.TrimSpace(e.Data.Message) + if msg == "" { + msg = e.Name + } + if e.Data.StatusCode == 429 || isPlanLimitMessage(msg) || isPlanLimitMessage(e.Data.ResponseBody) { + reset := parseResetTime(e.Data.ResponseHeaders) + a.mu.Lock() + a.lastLimitHit = true + a.lastLimitReset = reset + a.mu.Unlock() + term.PrintError("Coding-plan limit reached — " + msg + " (see /plan)") + return + } + if e.Data.StatusCode > 0 { + term.PrintError(fmt.Sprintf("opencode provider error (%d): %s", e.Data.StatusCode, msg)) + return + } + // opencode 1.0.x emits benign internal errors even on successful turns — e.g. + // an "UnknownError" schema-validation gripe on the trailing step event — with + // no HTTP status code. Real provider failures (auth, quota, 5xx) all carry a + // status code and are handled above, so surfacing these status-code-less + // events on every turn would just be noise. Show them only in verbose mode. + if a.outputVerbose { + term.PrintError("opencode error: " + msg) + } +} + +// LastTurnStats returns opencode's token usage for the most recent turn, when +// its stream carried any. Satisfies TurnStatsProvider. +func (a *OpenCodeAgent) LastTurnStats() (inputTokens, outputTokens int, ok bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastTurnIn, a.lastTurnOut, a.lastTurnOK +} + +// LastPlanLimit reports whether the plan limit was hit on the most recent turn +// and, when known, the provider-reported reset time. Satisfies PlanLimitReporter. +func (a *OpenCodeAgent) LastPlanLimit() (reset time.Time, hit bool) { + a.mu.Lock() + defer a.mu.Unlock() + return a.lastLimitReset, a.lastLimitHit +} + // ClearSession forgets the opencode session id so the next turn starts fresh // (used when the user types /clear). func (a *OpenCodeAgent) ClearSession() { diff --git a/internal/agent/opencode_stream_test.go b/internal/agent/opencode_stream_test.go new file mode 100644 index 0000000..6434fce --- /dev/null +++ b/internal/agent/opencode_stream_test.go @@ -0,0 +1,101 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/qualitymax/qmax-code/internal/tui" +) + +// ocSuccessStream is a real stream captured from +// `opencode run --format json --model opencode/deepseek-v4-flash-free` +// (opencode 1.0.105): a step-start, the text answer, then a benign trailing +// UnknownError with no statusCode that opencode emits even on a successful +// turn. The parser must return the answer, ignore the noise, and not invent +// token usage the stream never carried. +const ocSuccessStream = `{"type":"step_start","timestamp":1784907423206,"sessionID":"ses_06b3a6901ffeO62YWpcEfJj7ds","part":{"id":"prt_a","type":"step-start"}} +{"type":"text","timestamp":1784907424583,"sessionID":"ses_06b3a6901ffeO62YWpcEfJj7ds","part":{"id":"prt_b","type":"text","text":"pong"}} +{"type":"error","timestamp":1784907424833,"sessionID":"ses_06b3a6901ffeO62YWpcEfJj7ds","error":{"name":"UnknownError","data":{"message":"Invalid input"}}}` + +func TestOpenCodeParseSuccessStream(t *testing.T) { + a := &OpenCodeAgent{} + result := a.parseStream(strings.NewReader(ocSuccessStream), &tui.Terminal{}) + + if result != "pong" { + t.Errorf("result = %q, want %q", result, "pong") + } + if _, hit := a.LastPlanLimit(); hit { + t.Error("a benign UnknownError (no status code) must not be treated as a plan limit") + } + if _, _, ok := a.LastTurnStats(); ok { + t.Error("stream carried no usage event; LastTurnStats ok should be false") + } + if a.sessionID != "ses_06b3a6901ffeO62YWpcEfJj7ds" { + t.Errorf("sessionID = %q, want it captured from the stream", a.sessionID) + } +} + +// ocLimitStream models a coding-plan usage-limit refusal: a 429 error event +// carrying a Retry-After header. This is the case that previously produced no +// user-visible output at all. +const ocLimitStream = `{"type":"error","timestamp":1784907306260,"sessionID":"ses_limit001","error":{"name":"APIError","data":{"message":"Too Many Requests","statusCode":429,"responseHeaders":{"retry-after":"3600"}}}}` + +func TestOpenCodeParseLimitStream(t *testing.T) { + a := &OpenCodeAgent{} + a.parseStream(strings.NewReader(ocLimitStream), &tui.Terminal{}) + + reset, hit := a.LastPlanLimit() + if !hit { + t.Fatal("a 429 error should be detected as a plan-limit hit") + } + if reset.IsZero() { + t.Error("retry-after header should have produced a reset time") + } +} + +// ocSubscriptionStream is the real 403 captured when requesting a model that +// needs a paid subscription. It is a genuine provider error (has a status +// code) but NOT a usage-limit hit, so it should surface without flagging the +// window exhausted. +const ocSubscriptionStream = `{"type":"error","timestamp":1784907306260,"sessionID":"ses_sub0001","error":{"name":"APIError","data":{"message":"Forbidden","statusCode":403,"responseBody":"this model requires a subscription, upgrade for access"}}}` + +func TestOpenCodeParseSubscriptionErrorIsNotLimit(t *testing.T) { + a := &OpenCodeAgent{} + a.parseStream(strings.NewReader(ocSubscriptionStream), &tui.Terminal{}) + if _, hit := a.LastPlanLimit(); hit { + t.Error("a 403 subscription error is not a usage-limit hit") + } +} + +// TestOpenCodeParseUsageTokens is forward-compatible coverage: when an opencode +// version does emit token counts on a completion event — at the top level or +// on the part, under either the input/output or prompt/completion names — the +// parser folds them into the turn stats. +func TestOpenCodeParseUsageTokens(t *testing.T) { + cases := []struct { + name string + line string + wantIn, want int + }{ + { + name: "top-level tokens input/output", + line: `{"type":"step_finish","sessionID":"ses_usage01","tokens":{"input":1200,"output":345}}`, + wantIn: 1200, want: 345, + }, + { + name: "part usage prompt/completion", + line: `{"type":"step_finish","sessionID":"ses_usage02","part":{"type":"step-finish","usage":{"prompt":50,"completion":10}}}`, + wantIn: 50, want: 10, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := &OpenCodeAgent{} + a.parseStream(strings.NewReader(tc.line), &tui.Terminal{}) + in, out, ok := a.LastTurnStats() + if !ok || in != tc.wantIn || out != tc.want { + t.Errorf("LastTurnStats = %d,%d,%v; want %d,%d,true", in, out, ok, tc.wantIn, tc.want) + } + }) + } +} diff --git a/internal/agent/planlimit.go b/internal/agent/planlimit.go new file mode 100644 index 0000000..40c6708 --- /dev/null +++ b/internal/agent/planlimit.go @@ -0,0 +1,85 @@ +package agent + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +// isPlanLimitMessage reports whether an error string looks like a subscription +// plan usage-limit / rate-limit refusal rather than some other failure. Kept +// broad on purpose — providers phrase this differently (Anthropic "usage +// limit", OpenAI "rate limit", Z.AI/GLM "quota") and qmax-code only needs a +// good-enough signal to flag the coding-plan window as exhausted. +func isPlanLimitMessage(s string) bool { + s = strings.ToLower(s) + for _, needle := range []string{ + "rate limit", + "rate-limit", + "ratelimit", + "usage limit", + "usage-limit", + "quota", + "too many requests", + "limit reached", + "limit exceeded", + } { + if strings.Contains(s, needle) { + return true + } + } + return false +} + +// parseResetTime extracts a window reset time from an HTTP response's headers, +// so the plan window can show the provider's real reset instead of qmax-code's +// 5-hour estimate. It understands Retry-After (delta-seconds or HTTP date) and +// the common *-ratelimit-reset headers (epoch seconds, or seconds-from-now for +// small values). Returns the zero time when nothing usable is present. +func parseResetTime(headers map[string]string) time.Time { + if len(headers) == 0 { + return time.Time{} + } + get := func(key string) string { + for k, v := range headers { + if strings.EqualFold(k, key) { + return strings.TrimSpace(v) + } + } + return "" + } + + if ra := get("retry-after"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 { + return time.Now().Add(time.Duration(secs) * time.Second) + } + if t, err := http.ParseTime(ra); err == nil { + return t + } + } + + for _, key := range []string{ + "anthropic-ratelimit-unified-reset", + "x-ratelimit-reset", + "x-ratelimit-reset-requests", + "x-ratelimit-reset-tokens", + "ratelimit-reset", + } { + v := get(key) + if v == "" { + continue + } + if epoch, err := strconv.ParseInt(v, 10, 64); err == nil && epoch > 0 { + // Large values are unix epoch seconds; small ones are a delta. + if epoch > 1_000_000_000 { + return time.Unix(epoch, 0) + } + return time.Now().Add(time.Duration(epoch) * time.Second) + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + } + return time.Time{} +} diff --git a/internal/api/config.go b/internal/api/config.go index 526fe18..c554070 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -117,6 +117,13 @@ type Config struct { // users should opt in deliberately. Toggle via /live on|off or // /set live_feed true|false. LiveFeed bool `json:"live_feed,omitempty"` + + // PlanWindowHours overrides the assumed rolling usage-window length, in + // hours, of the active subscription coding plan (cc / codex / opencode). + // It drives the /plan tracker and the status-bar "resets in …" readout. + // 0 or unset means the 5-hour default (DefaultPlanWindowHours), which + // matches Claude Code, Codex, and OpenCode + Z.AI GLM plans. + PlanWindowHours int `json:"plan_window_hours,omitempty"` } const QmaxCodeConfigDir = ".qmax-code" diff --git a/internal/api/planwindow.go b/internal/api/planwindow.go new file mode 100644 index 0000000..33c9db7 --- /dev/null +++ b/internal/api/planwindow.go @@ -0,0 +1,167 @@ +package api + +import "time" + +// DefaultPlanWindowHours is the rolling usage-window length assumed for +// subscription coding plans (Claude Code, Codex, and OpenCode + Z.AI GLM). +// Those plans meter usage over a rolling window that opens on the first +// request and resets once the window elapses. Five hours matches the published +// behavior of all three at the time of writing; override it with the +// plan_window_hours config key for a plan whose window differs. +const DefaultPlanWindowHours = 5 + +// PlanWindow tracks the current rolling usage window of a subscription coding +// plan so the UI can show how much of it is spent and when it resets. +// +// It is a time-based estimate: qmax-code has no privileged access to the +// provider's real quota, so the window opens locally on the first orchestrated +// turn and rolls over once WindowLen elapses. When a provider does report an +// authoritative limit hit — e.g. a 429 carrying a reset/Retry-After header — +// MarkExhausted records it so the display reflects the real stop instead of the +// estimate. +// +// PlanWindow is not safe for concurrent use; the REPL drives it from the single +// turn loop, the same place it folds token usage into api.TokenUsage. +type PlanWindow struct { + WindowLen time.Duration // rolling window length (defaults to 5h) + StartedAt time.Time // zero until the first turn opens a window + Turns int // turns recorded in the current window + InputToks int // input tokens recorded in the current window + OutputToks int // output tokens recorded in the current window + + // Exhausted marks that the provider refused a turn because the plan limit + // was reached. ExhaustedReset is the provider-reported reset time when the + // error carried one, otherwise the zero value. + Exhausted bool + ExhaustedReset time.Time +} + +// NewPlanWindow returns a tracker with the given window length. A non-positive +// length falls back to the 5-hour default. +func NewPlanWindow(windowLen time.Duration) *PlanWindow { + if windowLen <= 0 { + windowLen = DefaultPlanWindowHours * time.Hour + } + return &PlanWindow{WindowLen: windowLen} +} + +// PlanWindowFromConfig builds a tracker honoring the plan_window_hours config +// value (0 or unset → the 5-hour default). +func PlanWindowFromConfig(cfg *Config) *PlanWindow { + hours := 0 + if cfg != nil { + hours = cfg.PlanWindowHours + } + return NewPlanWindow(time.Duration(hours) * time.Hour) +} + +// Record folds one completed turn into the window. If no window is open, or the +// open one has elapsed, a fresh window starts at now. Token counts may be zero +// for backends that do not report usage — the time window is still tracked, so +// "when it resets" stays accurate even when "how full" is unknown. +func (w *PlanWindow) Record(now time.Time, inputToks, outputToks int) { + if w == nil { + return + } + if w.StartedAt.IsZero() || now.Sub(w.StartedAt) >= w.WindowLen { + w.reset(now) + } + w.Turns++ + w.InputToks += inputToks + w.OutputToks += outputToks +} + +// MarkExhausted records that the provider refused a turn because the plan limit +// was reached. reset is the provider-reported reset time, or the zero value if +// the error carried none (the display then falls back to the window estimate). +func (w *PlanWindow) MarkExhausted(now time.Time, reset time.Time) { + if w == nil { + return + } + // A limit can be hit on the very first turn (before Record opened a + // window), so ensure one is considered open for the display. + if w.StartedAt.IsZero() { + w.StartedAt = now + } + w.Exhausted = true + w.ExhaustedReset = reset +} + +func (w *PlanWindow) reset(now time.Time) { + w.StartedAt = now + w.Turns = 0 + w.InputToks = 0 + w.OutputToks = 0 + w.Exhausted = false + w.ExhaustedReset = time.Time{} +} + +// Started reports whether any window has been opened yet. +func (w *PlanWindow) Started() bool { + return w != nil && !w.StartedAt.IsZero() +} + +// Active reports whether a window is currently open (started, and either not +// elapsed or flagged exhausted with time still on the provider's clock). +func (w *PlanWindow) Active(now time.Time) bool { + if !w.Started() { + return false + } + if w.Exhausted { + return w.Remaining(now) > 0 + } + return now.Sub(w.StartedAt) < w.WindowLen +} + +// Elapsed is how long the current window has been open (0 if none), clamped to +// WindowLen. +func (w *PlanWindow) Elapsed(now time.Time) time.Duration { + if !w.Started() { + return 0 + } + d := now.Sub(w.StartedAt) + if d < 0 { + return 0 + } + if d > w.WindowLen { + return w.WindowLen + } + return d +} + +// Remaining is how long until the current window resets (0 if none or elapsed). +// A provider-reported reset time wins over the local estimate. +func (w *PlanWindow) Remaining(now time.Time) time.Duration { + if !w.Started() { + return 0 + } + if w.Exhausted && !w.ExhaustedReset.IsZero() { + if d := w.ExhaustedReset.Sub(now); d > 0 { + return d + } + return 0 + } + if d := w.WindowLen - now.Sub(w.StartedAt); d > 0 { + return d + } + return 0 +} + +// ResetAt is the wall-clock time the current window resets (zero if none). +func (w *PlanWindow) ResetAt() time.Time { + if !w.Started() { + return time.Time{} + } + if w.Exhausted && !w.ExhaustedReset.IsZero() { + return w.ExhaustedReset + } + return w.StartedAt.Add(w.WindowLen) +} + +// TotalTokens returns the tokens recorded in the current window. +func (w *PlanWindow) TotalTokens() int { + if w == nil { + return 0 + } + return w.InputToks + w.OutputToks +} diff --git a/internal/api/planwindow_test.go b/internal/api/planwindow_test.go new file mode 100644 index 0000000..2b137b2 --- /dev/null +++ b/internal/api/planwindow_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "testing" + "time" +) + +func TestPlanWindowOpensAndAccumulates(t *testing.T) { + w := NewPlanWindow(5 * time.Hour) + base := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + + if w.Started() { + t.Fatal("window should not be started before first Record") + } + + w.Record(base, 100, 20) + w.Record(base.Add(10*time.Minute), 50, 10) + + if w.Turns != 2 { + t.Errorf("Turns = %d, want 2", w.Turns) + } + if w.InputToks != 150 || w.OutputToks != 30 { + t.Errorf("tokens = %d/%d, want 150/30", w.InputToks, w.OutputToks) + } + if got := w.TotalTokens(); got != 180 { + t.Errorf("TotalTokens = %d, want 180", got) + } + if !w.Active(base.Add(10 * time.Minute)) { + t.Error("window should be active 10m in") + } + if got, want := w.ResetAt(), base.Add(5*time.Hour); !got.Equal(want) { + t.Errorf("ResetAt = %v, want %v", got, want) + } + if got := w.Remaining(base.Add(1 * time.Hour)); got != 4*time.Hour { + t.Errorf("Remaining after 1h = %v, want 4h", got) + } +} + +func TestPlanWindowRollsOverAfterWindowLen(t *testing.T) { + w := NewPlanWindow(5 * time.Hour) + base := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + + w.Record(base, 100, 20) + w.Record(base.Add(2*time.Hour), 100, 20) // same window + if w.Turns != 2 { + t.Fatalf("Turns before rollover = %d, want 2", w.Turns) + } + + // A turn past the 5h mark opens a fresh window. + after := base.Add(5*time.Hour + time.Minute) + w.Record(after, 7, 3) + if w.Turns != 1 { + t.Errorf("Turns after rollover = %d, want 1", w.Turns) + } + if w.InputToks != 7 || w.OutputToks != 3 { + t.Errorf("tokens after rollover = %d/%d, want 7/3", w.InputToks, w.OutputToks) + } + if got, want := w.ResetAt(), after.Add(5*time.Hour); !got.Equal(want) { + t.Errorf("ResetAt after rollover = %v, want %v", got, want) + } +} + +func TestPlanWindowNotActiveAfterExpiry(t *testing.T) { + w := NewPlanWindow(5 * time.Hour) + base := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + w.Record(base, 1, 1) + + if w.Active(base.Add(5*time.Hour + time.Second)) { + t.Error("window should be inactive past its length") + } + if got := w.Remaining(base.Add(6 * time.Hour)); got != 0 { + t.Errorf("Remaining past expiry = %v, want 0", got) + } + if got := w.Elapsed(base.Add(6 * time.Hour)); got != 5*time.Hour { + t.Errorf("Elapsed clamps to WindowLen, got %v", got) + } +} + +func TestPlanWindowExhaustedUsesProviderReset(t *testing.T) { + w := NewPlanWindow(5 * time.Hour) + now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + w.Record(now, 10, 5) + + providerReset := now.Add(90 * time.Minute) + w.MarkExhausted(now, providerReset) + + if !w.Exhausted { + t.Fatal("window should be flagged exhausted") + } + if got := w.Remaining(now); got != 90*time.Minute { + t.Errorf("Remaining uses provider reset = %v, want 90m", got) + } + if got := w.ResetAt(); !got.Equal(providerReset) { + t.Errorf("ResetAt = %v, want provider reset %v", got, providerReset) + } + if !w.Active(now.Add(time.Hour)) { + t.Error("exhausted window with time left should stay active") + } +} + +func TestPlanWindowExhaustedOnFirstTurnWithoutReset(t *testing.T) { + w := NewPlanWindow(5 * time.Hour) + now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + + // Limit hit before any successful turn opened a window, and no reset header. + w.MarkExhausted(now, time.Time{}) + if !w.Started() { + t.Fatal("MarkExhausted should open a window for display") + } + // Falls back to the 5h estimate. + if got := w.Remaining(now); got != 5*time.Hour { + t.Errorf("Remaining falls back to estimate = %v, want 5h", got) + } +} + +func TestPlanWindowFromConfigDefault(t *testing.T) { + if got := PlanWindowFromConfig(nil).WindowLen; got != DefaultPlanWindowHours*time.Hour { + t.Errorf("nil config window = %v, want %v", got, DefaultPlanWindowHours*time.Hour) + } + if got := PlanWindowFromConfig(&Config{}).WindowLen; got != DefaultPlanWindowHours*time.Hour { + t.Errorf("zero-value config window = %v, want default", got) + } + if got := PlanWindowFromConfig(&Config{PlanWindowHours: 3}).WindowLen; got != 3*time.Hour { + t.Errorf("configured window = %v, want 3h", got) + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 13c1803..81cb260 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -167,6 +167,12 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin var lastTurnDur time.Duration var lastContextTokens int + // Coding-plan usage window — tracks the subscription plan's rolling 5-hour + // limit for the cc/codex/opencode backends so the status bar and /plan can + // show how much is spent and when it resets. Non-plan backends never open a + // window, so it stays inert for them. + planWindow := api.PlanWindowFromConfig(ag.AppConfig) + inputStatus := func() *tui.StatusInfo { backend := "api" if ag.Cfg.Context != nil && ag.Cfg.Context.Backend != "" { @@ -196,6 +202,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin ContextWindow: api.ContextWindow(model), LastTurnDur: lastTurnDur, SessionStarted: sessionStarted, + PlanActive: isPlanBackend(backend) && planWindow.Active(time.Now()), + PlanRemaining: planWindow.Remaining(time.Now()), + PlanTurns: planWindow.Turns, + PlanExhausted: planWindow.Exhausted, } } @@ -296,10 +306,17 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin reconnectMCPTransport(cliAgent, term) continue case input == "/status": - term.PrintStatusInfo(ag.Cfg.Context, ag.Usage, ag.Cfg.Model) + term.PrintStatusInfo(ag.Cfg.Context, ag.Usage, ag.Cfg.Model, planWindow) continue case input == "/cost": - term.PrintCostSummary(ag.Usage, ag.Cfg.Model) + term.PrintCostSummary(ag.Usage, ag.Cfg.Model, planWindow) + continue + case input == "/plan": + if !planWindow.Started() { + term.PrintSystem("No coding-plan window yet — run a turn on a cc/codex/opencode backend first.") + } else { + term.PrintPlanWindow(planWindow) + } continue case input == "/resume" || strings.HasPrefix(input, "/resume "): resumeTarget := strings.TrimPrefix(input, "/resume ") @@ -1190,6 +1207,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin llmResult, err = cliAgent.Run(cleanInput, term) term.StopThinking() if err == nil { + turnIn, turnOut := 0, 0 if stats, ok := cliAgent.(agent.TurnStatsProvider); ok { if inputTokens, outputTokens, reported := stats.LastTurnStats(); reported { ag.Usage.InputTokens += inputTokens @@ -1197,6 +1215,19 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin ag.Usage.Requests++ lastContextTokens = inputTokens ag.LastContextTokens = inputTokens + turnIn, turnOut = inputTokens, outputTokens + } + } + // Advance the coding-plan usage window for subscription + // backends — even when the harness reported no token usage, + // the rolling 5-hour clock still ticks on each turn. + if isPlanBackend(currentBackend(ag)) { + now := time.Now() + planWindow.Record(now, turnIn, turnOut) + if lim, ok := cliAgent.(agent.PlanLimitReporter); ok { + if reset, hit := lim.LastPlanLimit(); hit { + planWindow.MarkExhausted(now, reset) + } } } // Mirror the turn into ag.History so autoSave records it. @@ -1456,6 +1487,27 @@ func handleKeys(ag *agent.Agent, term *tui.Terminal) { } } +// isPlanBackend reports whether a backend runs on a subscription coding plan +// with a rolling usage window (Claude Code, Codex, or OpenCode + a provider +// plan like Z.AI GLM). The direct API, Cerebras, and Ollama backends bill +// per-token or run locally, so they have no such window. +func isPlanBackend(backend string) bool { + switch backend { + case "cc", "codex", "opencode": + return true + default: + return false + } +} + +// currentBackend returns the active backend id ("" for the direct API path). +func currentBackend(ag *agent.Agent) string { + if ag != nil && ag.Cfg.Context != nil { + return ag.Cfg.Context.Backend + } + return "" +} + func printHelp() { fmt.Println(` Commands: @@ -1480,6 +1532,7 @@ Commands: /project Set the active QualityMax project /context Show current session context /cost Show token usage and estimated cost + /plan Show coding-plan usage window (5h limit, resets in) /live [on|off] Toggle live browser feed for tests/crawls /feed Open the most recent live browser feed diff --git a/internal/tui/input.go b/internal/tui/input.go index fe89c51..1a82a01 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -29,6 +29,14 @@ type StatusInfo struct { ContextWindow int // assumed context window of the active model LastTurnDur time.Duration // wall-clock duration of the previous agent turn SessionStarted time.Time // used to keep the session timer live while typing + + // Subscription coding-plan usage window (cc / codex / opencode backends). + // PlanActive is false for non-plan backends and before the first turn, in + // which case the plan segment is omitted from the status line. + PlanActive bool + PlanRemaining time.Duration // time until the plan window resets + PlanTurns int // turns used in the current window + PlanExhausted bool // provider reported the plan limit was hit } // SlashMenuItem represents a selectable command. @@ -568,6 +576,13 @@ func (m inputModel) renderStatus(w int) string { metrics = append(metrics, "session "+compactDuration(sessionDur)) } } + if s.PlanActive { + if s.PlanExhausted { + metrics = append(metrics, fmt.Sprintf("plan limit — resets in %s", compactDuration(s.PlanRemaining))) + } else { + metrics = append(metrics, fmt.Sprintf("plan resets in %s · %d turns", compactDuration(s.PlanRemaining), s.PlanTurns)) + } + } if len(metrics) > 0 { lines = append(lines, statusMetricsStyle.Render(" "+strings.Join(metrics, " · "))) } diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index 2450fe3..56f4171 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -694,8 +694,9 @@ func (t *Terminal) PrintTokenUsage(usage api.TokenUsage) { usage.InputTokens, usage.OutputTokens, usage.TotalTokens(), usage.Requests)))) } -// PrintCostSummary shows detailed cost info for /cost command. -func (t *Terminal) PrintCostSummary(usage api.TokenUsage, model string) { +// PrintCostSummary shows detailed cost info for /cost command. When plan is +// non-nil and a coding-plan window is open, its usage is appended. +func (t *Terminal) PrintCostSummary(usage api.TokenUsage, model string, plan *api.PlanWindow) { cost := usage.EstimatedCost(model) fmt.Printf("\n") fmt.Printf(" %s\n", styleSystem.Render("Session Token Usage")) @@ -706,10 +707,38 @@ func (t *Terminal) PrintCostSummary(usage api.TokenUsage, model string) { fmt.Printf(" %-20s $%.4f\n", "Estimated cost:", cost) fmt.Printf(" %-20s %s\n", "Model:", model) fmt.Println() + t.PrintPlanWindow(plan) +} + +// PrintPlanWindow renders the subscription coding-plan usage window (the +// rolling 5-hour limit shared by cc / codex / opencode backends). It is a +// no-op when plan is nil or no window has opened yet, so it is safe to call +// unconditionally from /plan, /cost, and /status. +func (t *Terminal) PrintPlanWindow(plan *api.PlanWindow) { + if plan == nil || !plan.Started() { + return + } + now := time.Now() + fmt.Printf(" %s\n", styleSystem.Render("Coding-plan Usage Window")) + fmt.Printf(" %-20s %.0fh rolling\n", "Window length:", plan.WindowLen.Hours()) + fmt.Printf(" %-20s %s\n", "Elapsed:", compactDuration(plan.Elapsed(now))) + if plan.Exhausted { + fmt.Printf(" %-20s %s%s limit reached%s\n", "Status:", ColorYellow, "●", ColorReset) + } + fmt.Printf(" %-20s %s (at %s)\n", "Resets in:", + compactDuration(plan.Remaining(now)), plan.ResetAt().Local().Format("15:04")) + fmt.Printf(" %-20s %d\n", "Turns this window:", plan.Turns) + if plan.TotalTokens() > 0 { + fmt.Printf(" %-20s %d in / %d out\n", "Tokens this window:", plan.InputToks, plan.OutputToks) + } else { + fmt.Printf(" %-20s (backend reports no token usage)\n", "Tokens this window:") + } + fmt.Println(" Time-based estimate; the provider's real limit may differ.") + fmt.Println() } // PrintStatusInfo shows qmax status and session info for /status command. -func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage, model string) { +func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage, model string, plan *api.PlanWindow) { fmt.Println() fmt.Printf(" %s\n", styleSystem.Render("qmax-code Status")) @@ -742,6 +771,10 @@ func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage fmt.Printf(" %-20s %s\n", "Model:", model) fmt.Printf(" %-20s %d in / %d out\n", "Session tokens:", usage.InputTokens, usage.OutputTokens) fmt.Printf(" %-20s $%.4f\n", "Est. cost:", usage.EstimatedCost(model)) + if plan != nil && plan.Started() { + fmt.Printf(" %-20s resets in %s (%d turns)\n", "Plan window:", + compactDuration(plan.Remaining(time.Now())), plan.Turns) + } fmt.Println() }