Skip to content
Merged
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
14 changes: 13 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ The server plugin registers two lifecycle points:

A queued `auto` task transitions to `WAITING_OFFPEAK` and waits at whichever point it reaches first (normally `model.request`); its existing override still applies. In `auto` mode, a normal interactive request for a supported model is deferred the same way. It creates no task and no session, is tracked only in server memory (shown by `/offpeak status`), and is released exactly once when the plugin is enabled and the policy allows. Wake-ups come from the shared config-change signal plus one timer scheduled for the policy's `nextTransition`; there is no polling loop. If the plugin shuts down while a request is held, the request fails closed as blocked rather than being sent under an uncertain pricing state. Requests in `queue`/`guard` mode, and requests that arrive while the plugin is OFF, are blocked immediately by the transport guard; OFF does not release a request that is already held.

### Auxiliary requests and pending titles

OpenCode prepares auxiliary requests with the same session identity as the agent loop: `compaction` (context summarization inside a turn), `generate` (stateless text generation), and `title` (automatic chat naming). Compaction and generate share the turn's fate: if their hold is interrupted, the primary recovery continuation re-runs the step and the auxiliary request is prepared again. An automatic title does not: OpenCode generates it once when the first input of an untitled root session is promoted, and a lost title request is not retried by OpenCode itself.

A held `title` request therefore persists a semantic `pending-title` recovery marker (same minimal shape as a deferral: session ID, provider/model, location, timestamp, origin) instead of the primary continuation. A session can hold a primary continuation and a pending-title marker at the same time. When the allowed window arrives the marker is delivered as a check, never as a replay or a mutation:

- A session that already has a title — generated by OpenCode, generated by another process, or renamed by the user — clears the marker without any request, so a title is never overwritten and never generated twice.
- A session that still needs a title is left to OpenCode's own generation. Every path that loses a title hold (inactivity, supersede, shutdown, reload) also resumes the session — the plugin's same-session continuation or OpenCode's restart recovery — and that input promotion is OpenCode's native trigger for automatic naming. The marker waits for the title, clears once it appears, and is dropped with a diagnostic if no title appears within the settle cap; a later input promotion still names the session normally.
- OpenCode 2.0.27 exposes no plugin-facing regeneration call. The HTTP API's `PATCH /api/session/:id {"title": ""}` runs OpenCode's regeneration, but the plugin host's `session.update` (the only form a plugin can call) renames the session to an empty title instead. The recovery therefore mutates nothing; the `titleRecoveryWaiting` status field reports pending markers.

`UNKNOWN` stays fail-closed for every kind, including titles: a model with no policy entry produces no provider traffic at any hour until the policy is extended. The built-in Go policy avoids that for the models OpenCode actually requests by declaring the provider's documented flat-priced models explicitly (see `src/policy.ts` and `docs/pricing-policies.md`).

### Peak-aware retry recovery and the inactivity safety net

OpenCode retries retryable transport failures on its own (for the agent loop: jittered exponential backoff; header-timeout failures are bounded to three retries). Each retry attempt is a new paid request, and every attempt re-enters the whole hook chain, so a retry during peak would either be held or exhaust the retry budget and fail the turn.
Expand All @@ -73,7 +85,7 @@ OpenCode's location-activity sweep interrupts sessions whose location has had no

Non-retryable failures (`provider.invalid-request`, `provider.auth`, `provider.quota`, `provider.content-filter`, …) are left untouched and still end as `FAILED`/error. Failures while pricing is allowed keep OpenCode's normal retry behavior. OFF, `queue`, and `guard` modes are unchanged. The continuation is a new state-aware turn; the failed provider request is never replayed byte-for-byte. A recovery whose session never settles is dropped without sending anything.

Policy matching uses provider IDs and exact/glob model IDs. A configured provider with an unknown model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy.
Policy matching uses provider IDs and exact/glob model IDs. Each policy declares its peak-priced `models` (weekly `blocked` windows) and its `flatModels` (vendor-documented all-hours pricing, always OFF_PEAK and never held); `models` wins if a pattern matches both. A configured provider with an unmatched model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy.

The built-in `deepseek-opencode-go` policy is defined once in `src/policy.ts`, in UTC, with the Chinese statutory holiday calendar in `src/holidays.ts`. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST; holiday dates are evaluated on the calendar's own timezone (`Asia/Shanghai`) so the host-local timezone never matters. The user's display timezone does not change pricing boundaries. Full-day off-peak exception dates override every weekday peak window, and `nextTransition` skips holiday peak windows.

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.1.5 - 2026-10-15

- Automatic chat titles work again. OpenCode prepares automatic naming as a separate `title` model request that uses the provider's *small* model — for `opencode-go` that is `gpt-6-luna` — while the built-in policy only listed the DeepSeek models, so every title request was an `UNKNOWN` configured-provider model and failed closed at all hours. Titles stopped being generated when the plugin was installed, independent of peak pricing. The policy schema now supports `flatModels`: explicit, vendor-documented patterns for models with a single all-hours price (the Go pricing table lists peak/off-peak tiers only for the DeepSeek models). Flat models are `OFF_PEAK` at every hour, are never held or blocked, and cannot create peak-priced traffic; `models` takes precedence if a pattern matches both, and any unmatched model still fails closed. The built-in `deepseek-opencode-go` policy declares the provider's documented flat models, and an unmodified stored copy of the built-in policy adopts that list at read time.
- A held `title` request now persists a semantic `pending-title` recovery instead of being lost with the in-memory hold. A session can hold a primary continuation and a pending-title marker at the same time (v0.1.4 kept only one recovery entry per session, so a held title could shadow a primary deferral). At the allowed window the marker is delivered as a check, never a replay: a session that already has a title — generated by OpenCode or renamed by the user — clears the marker without any request, and a session that still needs one is left to OpenCode's own generation, which every interruption path (inactivity, supersede, shutdown, reload) re-triggers through the same input promotion that continues the session. `/offpeak status` reports pending markers as `titleRecoveryWaiting`.
- Recovery never mutates a session's title. OpenCode 2.0.27 exposes no plugin-facing regeneration call, and the plugin host's `session.update({ title: "" })` is not the HTTP API's regeneration — it renames the session to an empty title. The pending-title delivery therefore sends nothing of its own; a manual rename or a title generated by another process is never overwritten, and a title is never generated twice.
- The transport guard now receives the request kind and removes only the recovery class that belongs to the request that is actually crossing: a released `title` request clears a pending title, a released primary request clears a primary continuation, and neither can clear the other.
- Added deterministic regression coverage for flat-model pricing (allowed at every hour including peak windows, DeepSeek windows unchanged, overlap precedence, ambiguous and invalid lists failing closed), the pending-title marker (persistence across reload, manual-rename and already-titled safety, exact creation-time fallback still meaning untitled, child and unknown sessions, user cancel versus inactivity, at-most-once semantics across multiple reloads, coexistence with a primary deferral), and read-time adoption of the flat model list by an unmodified stored policy.

## 0.1.4 - 2026-10-14

- Peak waiting moved before the provider attempt. A normal interactive request (and a queue-owned auto task) that reaches peak pricing is now deferred at OpenCode v2's `model.request` hook, which runs while the request is being prepared and before the provider attempt (and its response-header timeout) exists.
Expand Down
10 changes: 9 additions & 1 deletion DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,15 @@ OpenCode 2.0.27 begins an attempt's response-header timeout (default 300s) at at

The DeepSeek pricing documentation defines the peak windows and the exemption: "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full." The OpenCode Go pricing page documents the same weekly windows for this provider and links to the DeepSeek page for the definition, and this pass was explicitly instructed to implement the confirmed holiday rule as part of the canonical policy. The policy model therefore supports explicit full-day OFF_PEAK exception calendars (`holidays`) that override every weekday peak window, and the built-in policy carries the 2026 China calendar from the State Council notice (国办发明电〔2025〕7号, published 2025-11-04) in `src/holidays.ts` with year, dates, source URL, verification date, and description. Holiday dates are evaluated on the calendar's own time zone (`Asia/Shanghai`), independent of the host and the policy display time zone. Adjusted weekend make-up workdays stay OFF_PEAK because weekends are always off-peak. `nextTransition` scans up to 14 days so long holiday runs cannot hide the next real transition, and a year without a calendar keeps the deterministic weekly schedule rather than guessing. Annual maintenance is required: the State Council publishes the next year's arrangement each November. Upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) (open, no vendor reply) reports peak-rate billing for Go during the 2026 National Day holiday; the October 1–7 Go billing comparison is not exposed per request (the provider offers only aggregate usage percentages), so that report is the cue for the annual re-check rather than a blocker for this confirmed rule.

A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates without editing the file, while any customized policy — including an explicit `holidays` list — is preserved exactly.
A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar *and the built-in flat model list* when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates and the flat model list without editing the file, while any customized policy — including an explicit `holidays` or `flatModels` list — is preserved exactly.

## 2026-10-15: Auxiliary title requests persist a pending-title marker; recovery never mutates the session

Automatic chat naming is a separate `title` model request. Like every model request it is priced and peak-protected, but OpenCode generates it only once, when the first input of an untitled root session is promoted, and it is not retried by OpenCode itself. A held title therefore persists a small `pending-title` recovery entry (same minimal metadata as a deferral, with `origin: "pending-title"`), and a session can hold a primary continuation and a title marker at the same time. Recovery at the allowed window is a semantic check, never a replay or mutation: a session that already has a title (generated, concurrent, or manually renamed) clears the marker without any request, and a session that still needs one is left to OpenCode's own generation, which every interruption path re-triggers through the same input promotion that continues the session. The marker waits for the title and clears when it appears; a stale marker is dropped with a diagnostic after the settle cap. Evidence: in real OpenCode 2.0.27 fixture runs, a PEAK hold plus a full server restart produced exactly one native title request through the recovery continuation's promotion, and the plugin's own attempted `context.session.update({ title: "" })` was proven unsafe — the plugin host renames the session to an empty title, unlike the HTTP handler's regenerate semantic — so the delivery deliberately mutates nothing and the driver exposes no such call.

## 2026-10-15: Vendor-documented flat-priced models are declared in the policy

OpenCode names sessions with the provider's small model (`model.small`), which for `opencode-go` is `gpt-6-luna`. The built-in policy only listed the DeepSeek models, so every title request was an `UNKNOWN` configured-provider model and failed closed at all hours — automatic titles stopped working when the plugin went live, independent of PEAK. The Go pricing table lists peak/off-peak tiers only for the DeepSeek models; every other documented Go model has one all-hours price. The policy schema therefore supports `flatModels` (explicit globs, `models` takes precedence, everything unmatched still fails closed) and the built-in policy declares the provider's documented flat models. This cannot create peak-priced traffic: it only marks models the vendor prices identically at every hour, and an unlisted future model stays `UNKNOWN` and blocked. Evidence: a live-shaped fixture run (provider `opencode-go`, models `deepseek-v4.1-flash` + `gpt-6-luna`, the stored pre-v0.1.5 policy copy) lost titles under v0.1.4 and generated them under v0.1.5 through the read-time adoption path.

## 2026-10-13: Peak-aware retry recovery uses OpenCode's retry hook

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing w
## Install

```sh
opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4
opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5
```

This installs the released `v0.1.4` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release.
This installs the released `v0.1.5` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release.

Restart OpenCode after installation. The plugin defaults to enabled, uses the current system timezone for display, and enforces the built-in DeepSeek/OpenCode Go policy without extra setup.

Expand Down Expand Up @@ -103,9 +103,9 @@ Holiday dates come from the official State Council calendar kept in `src/holiday

The direct DeepSeek pricing documentation defines the exemption ("…excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."), and the OpenCode Go pricing page documents the same weekly windows for this provider. The Go price table lists peak DeepSeek rates at twice their off-peak per-token rates, so equal token use consumes the allowance twice as fast during peak hours. Re-verify both pages annually; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open user report about National Day holiday billing and is the cue for a fresh check.

The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. An unrecognized model under a configured provider fails closed. A provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy.
The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. Every other Go model documented in the pricing table has a single all-hours price and is declared as a flat model: it is never peak-priced, so it is never held or blocked. That list mirrors the provider's own table, because OpenCode names automatic session titles with the provider's small model — for `opencode-go` that is `gpt-6-luna` — and a title must not be blocked as an unknown model. A model that matches neither list under a configured provider fails closed; a provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy.

Pricing details can change. This release verified the built-in schedule and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-14. Review those sources before relying on a later policy update.
Pricing details can change. This release verified the built-in schedule, the flat model list, and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-15. Review those sources before relying on a later policy update.

## Configuration

Expand Down
Loading
Loading