From 4caf99d3c0f2dc8eec523f9e171775022a7b6498 Mon Sep 17 00:00:00 2001 From: DavidSegun Date: Fri, 24 Jul 2026 17:22:27 +0100 Subject: [PATCH 1/5] feat(orch): track subscription coding-plan 5-hour usage window Orchestration backends (Claude Code, Codex, OpenCode + Z.AI GLM) run on subscription plans that meter usage over a rolling ~5-hour window, but qmax-code never tracked it, so it was unclear how much was used or when the plan would stop. Add a time-based PlanWindow tracker (internal/api/planwindow.go) that opens on the first orch turn, accumulates turns/tokens, rolls over after the window elapses, and can be marked exhausted from a provider limit hit. Surface it in the input status bar and a new /plan command (also summarized in /status and /cost). Window length is configurable via plan_window_hours (default 5). Also close two backend blind spots that fed the same problem: - OpenCode and Codex now report per-turn token usage (previously CC-only), feeding both session cost and the plan window. - OpenCode `{type:error}` events were being silently dropped; they are now surfaced, and a 429 / usage-limit refusal is detected as a plan-limit hit, reading the reset time from rate-limit headers when present. Build + vet clean; new planwindow unit tests pass. Codex/OpenCode token-field names are parsed defensively pending confirmation against a live successful run. --- CHANGELOG.md | 17 ++++ README.md | 1 + config_command.go | 21 ++++ docs/COMMANDS.md | 2 + internal/agent/cc_agent.go | 10 ++ internal/agent/codex_agent.go | 84 ++++++++++++++++ internal/agent/opencode_agent.go | 133 ++++++++++++++++++++++-- internal/agent/planlimit.go | 85 ++++++++++++++++ internal/api/config.go | 7 ++ internal/api/planwindow.go | 167 +++++++++++++++++++++++++++++++ internal/api/planwindow_test.go | 126 +++++++++++++++++++++++ internal/repl/repl.go | 57 ++++++++++- internal/tui/input.go | 15 +++ internal/tui/terminal.go | 39 +++++++- 14 files changed, 752 insertions(+), 12 deletions(-) create mode 100644 internal/agent/planlimit.go create mode 100644 internal/api/planwindow.go create mode 100644 internal/api/planwindow_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b78a8..4454313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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. + ## [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..dd5facd 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -43,6 +43,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 @@ -122,6 +127,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() @@ -195,16 +202,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 +297,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 +359,46 @@ 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 + } + 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/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() } From 9f170a6db52c6497d2280585b8426aaa0341149f Mon Sep 17 00:00:00 2001 From: DavidSegun Date: Fri, 24 Jul 2026 17:22:28 +0100 Subject: [PATCH 2/5] fix(orch): suppress benign opencode error noise; add real-stream parser tests Verified against live `opencode run --format json` output (opencode 1.0.105): successful turns emit a trailing status-code-less UnknownError (an internal schema-validation gripe), and this version emits no token-usage event in the stream. Surfacing every error event would therefore spam a scary line after every good turn, so only errors carrying an HTTP status code (auth/quota/5xx) and detected usage-limit hits are shown by default; status-code-less events appear only in verbose mode. Adds opencode_stream_test.go driving the parser with the real captured streams: success (renders answer, no false limit, no invented tokens), a 429 limit hit (flags the window + reads Retry-After), a 403 subscription error (surfaced but not a limit), and forward-compat token extraction for both field shapes. --- CHANGELOG.md | 3 + internal/agent/opencode_agent.go | 9 ++- internal/agent/opencode_stream_test.go | 101 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 internal/agent/opencode_stream_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4454313..8547912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// 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 diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index dd5facd..6e9a4b6 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -380,7 +380,14 @@ func (a *OpenCodeAgent) handleOCError(e *ocError, term *tui.Terminal) { term.PrintError(fmt.Sprintf("opencode provider error (%d): %s", e.Data.StatusCode, msg)) return } - term.PrintError("opencode error: " + msg) + // 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 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) + } + }) + } +} From a0c50fd65c6b49a726d371205f3eb055f5a92594 Mon Sep 17 00:00:00 2001 From: DavidSegun Date: Fri, 24 Jul 2026 18:45:00 +0100 Subject: [PATCH 3/5] fix(orch): only pass `opencode run --auto` when the installed version supports it opencode 1.x removed the `run --auto` flag and now governs tool approvals through the config `permission` block. qmax-code passed --auto unconditionally, so on current opencode every turn failed: opencode printed its usage and exited 1 with no output ("opencode exited with error: exit status 1"), making the OpenCode backend (incl. Z.AI GLM coding plans) unusable. Probe `opencode run --help` once and only append --auto when the flag is still advertised, preserving behavior on older opencode while working on 1.x. The managed config's permission block already enforces the standard/unattended policy, so nothing is auto-approved that was not before. Verified against opencode 1.0.105: without --auto the run produces the answer and the turn completes. --- internal/agent/opencode_agent.go | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 6e9a4b6..4a6a295 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -71,6 +71,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"). @@ -146,10 +172,15 @@ 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) } From 12ecdeaddffeb0240b8e0c111966605093864707 Mon Sep 17 00:00:00 2001 From: DavidSegun Date: Fri, 24 Jul 2026 19:32:27 +0100 Subject: [PATCH 4/5] fix(orch): don't pass the `--` arg separator to opencode on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, npm installs opencode as a `.cmd` shim. Go's os/exec runs `.cmd` files through cmd.exe, which swallows the `--` end-of-flags separator and drops the positional message that follows it — opencode then aborts with "You must provide a message or a command" and exits 1, so no OpenCode turn (including Z.AI GLM / OpenRouter coding plans) can run on Windows. Pass the message without `--` on Windows (sanitizeCCUserPrompt already strips control bytes, so a lone positional is safely taken as the message); keep `--` on other platforms where it protects a message starting with "-". Verified via a Go exec repro: with `--` the message is dropped, without it the turn runs. --- internal/agent/opencode_agent.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 4a6a295..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" @@ -185,8 +186,17 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) 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() From bcda756144e47474c43854fb1a5ddb3abccdb476 Mon Sep 17 00:00:00 2001 From: Ruslan Strazhnyk Date: Tue, 4 Aug 2026 19:19:03 +0200 Subject: [PATCH 5/5] fix(orch): address the four plan-tracking review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the review on #157, which had been outstanding since 2026-07-25. 1. BLOCKER — limit hits could bypass exhaustion tracking. LastPlanLimit() was read only inside `if err == nil`, but a limit refusal that produces no assistant output makes the CLI subprocess exit non-zero, so the usual 429 path set the agent's limit flag and never called MarkExhausted. The bookkeeping now runs whether or not the turn errored: a successful turn still advances the window, and the limit state is consumed either way. Extracted as recordPlanTurn so the behaviour is unit-testable rather than buried in the REPL closure. 2. Provider reset times now roll the window over. Record() rolled over only after WindowLen, so when a provider reported an earlier authoritative reset the first accepted turn after it landed in the old exhausted window and the UI kept showing a limit that no longer applied. Rollover now uses the window's effective ResetAt(). 3. Live backend switching no longer mixes independent quotas. One tracker was allocated per session while /cc, /codex, and /opencode switch plans mid -session, combining turns and exhaustion state across separate subscriptions. Trackers are now keyed by quota, with OpenCode keyed per provider since its providers meter separately. 4. OpenCode no longer undercounts multi-step turns. The parser overwrote lastTurnIn/lastTurnOut on every usage-bearing event, keeping only the final step; opencode emits usage on each step-finish, so tool loops lost every earlier step. Usage now accumulates across steps, taking one canonical payload per event (the four shapes are the same numbers in different places) and skipping any step id already counted. Tests: two-step and repeated-step parser tests, provider-reset rollover boundary tests, failed-run exhaustion tests, and quota-keying tests. go build, go test ./..., and go vet ./... all pass. --- internal/agent/opencode_agent.go | 48 +++++++++++- internal/agent/opencode_stream_test.go | 61 +++++++++++++++ internal/api/planwindow.go | 15 +++- internal/api/planwindow_test.go | 49 ++++++++++++ internal/repl/planwindow_turn_test.go | 100 +++++++++++++++++++++++++ internal/repl/repl.go | 88 ++++++++++++++++------ 6 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 internal/repl/planwindow_turn_test.go diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 07035a0..4762209 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -275,6 +276,32 @@ type ocTokens struct { Completion int `json:"completion"` } +// tokens returns the one canonical usage payload carried by an event. The four +// locations are the same numbers reported by different opencode/provider +// versions, so the first populated shape wins rather than being summed. +func (e *ocEvent) tokens() (in, out int, ok bool) { + for _, tk := range []*ocTokens{e.Tokens, e.Usage, e.Part.Tokens, e.Part.Usage} { + if i, o := tk.in(), tk.out(); i > 0 || o > 0 { + return i, o, true + } + } + return 0, 0, false +} + +// usageKey identifies the step an event's usage belongs to, so a re-emitted +// step is not counted twice. An empty result means the event carries nothing +// stable to key on and must be counted as its own step — undercounting a +// multi-step turn is the failure this accumulation exists to prevent. +func (e *ocEvent) usageKey() string { + if e.Part.ID != "" { + return "part:" + e.Part.ID + } + if e.Timestamp != 0 { + return "ts:" + e.Type + ":" + strconv.FormatInt(e.Timestamp, 10) + } + return "" +} + func (t *ocTokens) in() int { if t == nil { return 0 @@ -321,6 +348,7 @@ func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) textByPart := map[string]string{} // part id → latest full text var order []string // text part ids in first-seen order seenTool := map[string]bool{} // tool part ids already announced + countedUsage := map[string]bool{} // usage keys already folded into the turn total for scanner.Scan() { line := scanner.Bytes() @@ -344,11 +372,23 @@ func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) 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 { + // Accumulate token usage across the turn. opencode reports usage once + // per step (step-finish), so a turn that calls tools carries several + // token-bearing events; overwriting would keep only the final step and + // undercount every multi-step turn. The four shapes below are the same + // usage in different places rather than separate counts, so exactly one + // canonical payload is taken per event, and any step already counted is + // skipped in case opencode re-emits it. + if in, out, ok := ev.tokens(); ok { + key := ev.usageKey() + if key == "" || !countedUsage[key] { + if key != "" { + countedUsage[key] = true + } a.mu.Lock() - a.lastTurnIn, a.lastTurnOut, a.lastTurnOK = in, out, true + a.lastTurnIn += in + a.lastTurnOut += out + a.lastTurnOK = true a.mu.Unlock() } } diff --git a/internal/agent/opencode_stream_test.go b/internal/agent/opencode_stream_test.go index 6434fce..23e1890 100644 --- a/internal/agent/opencode_stream_test.go +++ b/internal/agent/opencode_stream_test.go @@ -99,3 +99,64 @@ func TestOpenCodeParseUsageTokens(t *testing.T) { }) } } + +// ocMultiStepStream models a turn that calls a tool: opencode emits usage on +// each step-finish, so the turn's real cost is the sum across steps. Keeping +// only the last step — as the parser previously did by overwriting — reports +// 30/6 for a turn that actually cost 150/45. +const ocMultiStepStream = `{"type":"step_start","timestamp":1784907423206,"sessionID":"ses_multi","part":{"id":"prt_s1","type":"step-start"}} +{"type":"tool","timestamp":1784907423500,"sessionID":"ses_multi","part":{"id":"prt_t1","type":"tool","tool":"read"}} +{"type":"step_finish","timestamp":1784907423900,"sessionID":"ses_multi","part":{"id":"prt_s1","type":"step-finish"},"tokens":{"input":120,"output":39}} +{"type":"text","timestamp":1784907424583,"sessionID":"ses_multi","part":{"id":"prt_b","type":"text","text":"done"}} +{"type":"step_finish","timestamp":1784907424900,"sessionID":"ses_multi","part":{"id":"prt_s2","type":"step-finish"},"tokens":{"input":30,"output":6}}` + +func TestOpenCodeAccumulatesUsageAcrossSteps(t *testing.T) { + a := &OpenCodeAgent{} + result := a.parseStream(strings.NewReader(ocMultiStepStream), &tui.Terminal{}) + + if result != "done" { + t.Errorf("result = %q, want %q", result, "done") + } + in, out, ok := a.LastTurnStats() + if !ok { + t.Fatal("a stream carrying usage must report stats") + } + if in != 150 || out != 45 { + t.Fatalf("LastTurnStats = %d/%d, want 150/45 summed across both steps", in, out) + } +} + +// ocRepeatedStepStream re-emits one step-finish, which opencode does for +// snapshot-style events. The same step must not be counted twice. +const ocRepeatedStepStream = `{"type":"step_finish","timestamp":1784907423900,"sessionID":"ses_rep","part":{"id":"prt_s1","type":"step-finish"},"tokens":{"input":100,"output":20}} +{"type":"step_finish","timestamp":1784907423900,"sessionID":"ses_rep","part":{"id":"prt_s1","type":"step-finish"},"tokens":{"input":100,"output":20}}` + +func TestOpenCodeDoesNotDoubleCountARepeatedStep(t *testing.T) { + a := &OpenCodeAgent{} + a.parseStream(strings.NewReader(ocRepeatedStepStream), &tui.Terminal{}) + + in, out, ok := a.LastTurnStats() + if !ok { + t.Fatal("expected usage to be reported") + } + if in != 100 || out != 20 { + t.Fatalf("LastTurnStats = %d/%d, want 100/20 counted once", in, out) + } +} + +// ocDualShapeStream carries the same usage in two locations, which different +// opencode/provider versions do. They are one payload, not two counts. +const ocDualShapeStream = `{"type":"step_finish","timestamp":1784907423900,"sessionID":"ses_dual","part":{"id":"prt_s1","type":"step-finish","tokens":{"input":80,"output":10}},"tokens":{"input":80,"output":10}}` + +func TestOpenCodeCountsOneCanonicalPayloadPerEvent(t *testing.T) { + a := &OpenCodeAgent{} + a.parseStream(strings.NewReader(ocDualShapeStream), &tui.Terminal{}) + + in, out, ok := a.LastTurnStats() + if !ok { + t.Fatal("expected usage to be reported") + } + if in != 80 || out != 10 { + t.Fatalf("LastTurnStats = %d/%d, want 80/10 — the shapes are one payload", in, out) + } +} diff --git a/internal/api/planwindow.go b/internal/api/planwindow.go index 33c9db7..5878759 100644 --- a/internal/api/planwindow.go +++ b/internal/api/planwindow.go @@ -56,14 +56,21 @@ func PlanWindowFromConfig(cfg *Config) *PlanWindow { } // 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. +// open one has reached its reset time, 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. +// +// Rollover uses the window's effective reset time rather than WindowLen alone. +// When a provider reported an authoritative reset — a 429 carrying Retry-After, +// say — that time wins over the local estimate, so a turn the provider accepts +// after it opens a fresh window instead of landing in the exhausted one and +// leaving the UI showing a limit that no longer applies. func (w *PlanWindow) Record(now time.Time, inputToks, outputToks int) { if w == nil { return } - if w.StartedAt.IsZero() || now.Sub(w.StartedAt) >= w.WindowLen { + if w.StartedAt.IsZero() || !now.Before(w.ResetAt()) { w.reset(now) } w.Turns++ diff --git a/internal/api/planwindow_test.go b/internal/api/planwindow_test.go index 2b137b2..42771f8 100644 --- a/internal/api/planwindow_test.go +++ b/internal/api/planwindow_test.go @@ -124,3 +124,52 @@ func TestPlanWindowFromConfigDefault(t *testing.T) { t.Errorf("configured window = %v, want 3h", got) } } + +func TestRecordRollsOverAtProviderReportedReset(t *testing.T) { + // The failure this guards: the provider says the quota frees at 13:30, but + // Record only rolled over after the full 5-hour WindowLen, so the first + // accepted turn after 13:30 landed in the exhausted window and the UI kept + // showing a limit that no longer applied. + start := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + reset := start.Add(90 * time.Minute) + + w := NewPlanWindow(5 * time.Hour) + w.Record(start, 100, 20) + w.MarkExhausted(start.Add(time.Minute), reset) + + // Before the provider's reset, the window stays exhausted. + w.Record(reset.Add(-time.Minute), 10, 5) + if !w.Exhausted { + t.Fatal("a turn before the reported reset must not clear the exhausted window") + } + + // At or after it, a new window opens. + w.Record(reset, 30, 7) + if w.Exhausted { + t.Fatal("a turn at the reported reset must open a fresh window") + } + if !w.StartedAt.Equal(reset) { + t.Fatalf("StartedAt = %v, want %v", w.StartedAt, reset) + } + if w.Turns != 1 || w.InputToks != 30 || w.OutputToks != 7 { + t.Fatalf("fresh window carried old totals: turns=%d in=%d out=%d", w.Turns, w.InputToks, w.OutputToks) + } + if !w.ExhaustedReset.IsZero() { + t.Fatalf("ExhaustedReset must clear on rollover, got %v", w.ExhaustedReset) + } +} + +func TestRecordStillRollsOverOnWindowLengthWithoutAProviderReset(t *testing.T) { + start := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + w := NewPlanWindow(5 * time.Hour) + w.Record(start, 10, 10) + + w.Record(start.Add(4*time.Hour), 5, 5) + if w.Turns != 2 { + t.Fatalf("a turn inside the window must accumulate, got Turns = %d", w.Turns) + } + w.Record(start.Add(5*time.Hour), 1, 1) + if w.Turns != 1 || !w.StartedAt.Equal(start.Add(5*time.Hour)) { + t.Fatalf("window must roll over at WindowLen: turns=%d startedAt=%v", w.Turns, w.StartedAt) + } +} diff --git a/internal/repl/planwindow_turn_test.go b/internal/repl/planwindow_turn_test.go new file mode 100644 index 0000000..4839e45 --- /dev/null +++ b/internal/repl/planwindow_turn_test.go @@ -0,0 +1,100 @@ +package repl + +import ( + "errors" + "testing" + "time" + + "github.com/qualitymax/qmax-code/internal/api" +) + +// limitReporter is a CLI agent stub that reports a plan-limit hit. +type limitReporter struct { + reset time.Time + hit bool +} + +func (l *limitReporter) LastPlanLimit() (time.Time, bool) { return l.reset, l.hit } + +// plainAgent implements no optional reporter interfaces. +type plainAgent struct{} + +func TestRecordPlanTurnMarksExhaustedWhenTheRunFailed(t *testing.T) { + // The failure this guards: a 429 that produces no assistant output makes + // the CLI subprocess exit non-zero, so the limit arrives with runErr != nil. + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + reset := now.Add(90 * time.Minute) + w := api.NewPlanWindow(5 * time.Hour) + + recordPlanTurn(w, now, errors.New("opencode exited with status 1"), 0, 0, &limitReporter{reset: reset, hit: true}) + + if !w.Exhausted { + t.Fatal("a limit hit reported alongside a run error must still exhaust the window") + } + if !w.ExhaustedReset.Equal(reset) { + t.Fatalf("ExhaustedReset = %v, want %v", w.ExhaustedReset, reset) + } + if !w.Started() { + t.Fatal("a limit on the first turn must still open a window for the display") + } + if w.Turns != 0 { + t.Fatalf("a failed turn must not be counted, got Turns = %d", w.Turns) + } +} + +func TestRecordPlanTurnRecordsOnlySuccessfulTurns(t *testing.T) { + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + w := api.NewPlanWindow(5 * time.Hour) + + recordPlanTurn(w, now, nil, 120, 45, plainAgent{}) + if w.Turns != 1 || w.InputToks != 120 || w.OutputToks != 45 { + t.Fatalf("successful turn not recorded: turns=%d in=%d out=%d", w.Turns, w.InputToks, w.OutputToks) + } + if w.Exhausted { + t.Fatal("an agent that reports no limit must not exhaust the window") + } + + recordPlanTurn(w, now.Add(time.Minute), errors.New("boom"), 999, 999, plainAgent{}) + if w.Turns != 1 || w.InputToks != 120 { + t.Fatalf("failed turn must not advance the window: turns=%d in=%d", w.Turns, w.InputToks) + } +} + +func TestRecordPlanTurnToleratesNilWindow(t *testing.T) { + recordPlanTurn(nil, time.Now(), nil, 1, 1, &limitReporter{hit: true}) +} + +func TestPlanWindowKeySeparatesQuotas(t *testing.T) { + // /cc, /codex, and /opencode draw on independent subscriptions, and + // OpenCode's providers are metered separately from each other. + cases := []struct { + name string + backend string + cfg *api.Config + want string + }{ + {"claude code", "cc", &api.Config{}, "cc"}, + {"codex", "codex", &api.Config{}, "codex"}, + {"opencode without a model override", "opencode", &api.Config{}, "opencode"}, + {"opencode on zai", "opencode", &api.Config{ModelOverride: "zai/glm-4.6"}, "opencode/zai"}, + {"opencode on groq", "opencode", &api.Config{ModelOverride: "groq/llama-3.3-70b"}, "opencode/groq"}, + {"opencode with a bare model name", "opencode", &api.Config{ModelOverride: "glm-4.6"}, "opencode"}, + {"nil config", "opencode", nil, "opencode"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := planWindowKey(tc.backend, tc.cfg); got != tc.want { + t.Fatalf("planWindowKey(%q) = %q, want %q", tc.backend, got, tc.want) + } + }) + } + + zai := planWindowKey("opencode", &api.Config{ModelOverride: "zai/glm-4.6"}) + groq := planWindowKey("opencode", &api.Config{ModelOverride: "groq/llama-3.3-70b"}) + if zai == groq { + t.Fatal("two OpenCode providers must not share one usage window") + } + if planWindowKey("cc", &api.Config{}) == planWindowKey("codex", &api.Config{}) { + t.Fatal("Claude Code and Codex must not share one usage window") + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 81cb260..cf47d2e 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -167,11 +167,22 @@ 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) + // Coding-plan usage windows — one tracker per subscription quota, so the + // status bar and /plan can show how much of the rolling 5-hour limit is + // spent and when it resets. /cc, /codex, and /opencode switch between + // independent plans mid-session, so a single shared tracker would combine + // turns and exhaustion state belonging to different subscriptions. + // Non-plan backends never record, so their trackers stay inert. + planWindows := map[string]*api.PlanWindow{} + planWindowFor := func(backend string) *api.PlanWindow { + key := planWindowKey(backend, ag.AppConfig) + w, ok := planWindows[key] + if !ok { + w = api.PlanWindowFromConfig(ag.AppConfig) + planWindows[key] = w + } + return w + } inputStatus := func() *tui.StatusInfo { backend := "api" @@ -189,6 +200,8 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin permissionMode = ag.AppConfig.OrchPermissionMode } } + // Report the window belonging to the backend that is actually active. + planWindow := planWindowFor(backend) return &tui.StatusInfo{ Backend: backend, Model: model, @@ -306,16 +319,16 @@ 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, planWindow) + term.PrintStatusInfo(ag.Cfg.Context, ag.Usage, ag.Cfg.Model, planWindowFor(currentBackend(ag))) continue case input == "/cost": - term.PrintCostSummary(ag.Usage, ag.Cfg.Model, planWindow) + term.PrintCostSummary(ag.Usage, ag.Cfg.Model, planWindowFor(currentBackend(ag))) continue case input == "/plan": - if !planWindow.Started() { + if w := planWindowFor(currentBackend(ag)); !w.Started() { term.PrintSystem("No coding-plan window yet — run a turn on a cc/codex/opencode backend first.") } else { - term.PrintPlanWindow(planWindow) + term.PrintPlanWindow(w) } continue case input == "/resume" || strings.HasPrefix(input, "/resume "): @@ -1206,8 +1219,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.StartThinking() llmResult, err = cliAgent.Run(cleanInput, term) term.StopThinking() + + turnIn, turnOut := 0, 0 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 @@ -1218,18 +1232,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin 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) - } - } - } + } + + if isPlanBackend(currentBackend(ag)) { + recordPlanTurn(planWindowFor(currentBackend(ag)), time.Now(), err, turnIn, turnOut, cliAgent) + } + + if err == nil { // Mirror the turn into ag.History so autoSave records it. ag.History = append(ag.History, api.Message{Role: "user", Content: cleanInput}, @@ -1500,6 +1509,41 @@ func isPlanBackend(backend string) bool { } } +// recordPlanTurn folds one CLI-backend turn into its plan usage window. +// +// It runs whether or not the turn returned an error. A successful turn advances +// the window: even when the harness reported no token usage, the rolling clock +// still ticks. A limit hit is consumed either way, because a refusal that +// produces no assistant output makes the subprocess exit non-zero — reading the +// reporter only on success would set the agent's limit flag and never mark the +// window exhausted, which is the state the whole feature exists to show. +func recordPlanTurn(w *api.PlanWindow, now time.Time, runErr error, turnIn, turnOut int, cliAgent any) { + if w == nil { + return + } + if runErr == nil { + w.Record(now, turnIn, turnOut) + } + if lim, ok := cliAgent.(agent.PlanLimitReporter); ok { + if reset, hit := lim.LastPlanLimit(); hit { + w.MarkExhausted(now, reset) + } + } +} + +// planWindowKey identifies the subscription quota a backend draws on, so each +// plan gets its own usage window. OpenCode fronts several providers whose plans +// are metered separately, so its key carries the provider segment of the +// configured provider/model override. +func planWindowKey(backend string, cfg *api.Config) string { + if backend == "opencode" && cfg != nil && cfg.ModelOverride != "" { + if provider, _, found := strings.Cut(cfg.ModelOverride, "/"); found && provider != "" { + return "opencode/" + provider + } + } + return backend +} + // currentBackend returns the active backend id ("" for the direct API path). func currentBackend(ag *agent.Agent) string { if ag != nil && ag.Cfg.Context != nil {