Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ agent. qmax-code declares that dependency in the Codex skill metadata.
/queue <prompt> 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
```
Expand Down
21 changes: 21 additions & 0 deletions config_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/agent/cc_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
84 changes: 84 additions & 0 deletions internal/agent/codex_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.).
Expand Down
Loading
Loading