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
12 changes: 11 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The plugin does not run a second agent runtime. OpenCode owns the sessions, mode

## Compatibility enforcement

Both entrypoints are defined with `Plugin.define(...)` and start by checking `context.app.version` against the tested range `>=2.0.26 <2.1.0` (validated against OpenCode 2.0.26, 2.0.27, and 2.0.30; the same range declared in `package.json` `engines.opencode`). OpenCode releases before 2.0.26 activate TUI plugins outside the Solid keymap provider owner, so `context.keymap.layer()` fails with `Keymap.Provider is missing` there even though the plugin follows the documented API. On an unsupported host the plugin refuses to initialize with an explicit, actionable version error instead of reaching that confusing runtime failure.
Both entrypoints are defined with `Plugin.define(...)` and start by checking `context.app.version` against the tested range `>=2.0.26 <2.2.0` (validated against OpenCode 2.0.26, 2.0.27, 2.0.30, and 2.1.3; the same range declared in `package.json` `engines.opencode`). OpenCode 2.1's session/compaction schema change (`providerState` → `native`) does not affect the hooks used here; its internal retry of buffered model calls only changes how quickly a failed probe settles. OpenCode releases before 2.0.26 activate TUI plugins outside the Solid keymap provider owner, so `context.keymap.layer()` fails with `Keymap.Provider is missing` there even though the plugin follows the documented API. On an unsupported host the plugin refuses to initialize with an explicit, actionable version error instead of reaching that confusing runtime failure.

```mermaid
flowchart LR
Expand Down Expand Up @@ -62,6 +62,16 @@ 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.

### Read-only pricing query

`pricing` is the second RPC method and the single canonical classification
surface for other plugins. It takes `{ providerID, modelID }`, calls the same
`evaluatePricing` used by every guard with the same stored policies and clock,
and returns `{ state, allowed, policyID?, nextTransition?, reason? }`. It has no
queue, hold, timer, or persistence side effect. `opencode-free-first` consumes it
for `peak-only`; this plugin remains the only owner of the weekly windows and the
Chinese holiday calendar.

### Cancelling a held request

A caller that owns a routing decision can cancel the request this plugin is holding for a session through the `cancelHold` RPC method. `state` on the held request is the single authoritative transition, and it is mutated synchronously:
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 0.2.0 - 2026-10-23

### Added

- **Read-only `pricing` RPC.** `{ providerID, modelID }` returns
`{ state, allowed, policyID?, nextTransition?, reason? }` from the same
`evaluatePricing` used by the guards, with no queue, hold, timer, provider
request, or persisted-state side effect. It is the canonical PEAK/OFF_PEAK
source for `opencode-free-first` `freeUsageMode: "peak-only"`, which needs no
copy of the weekly windows or Chinese holiday calendar.

### Compatibility

- **OpenCode `>=2.0.26 <2.2.0`**, validated against the current production
**2.1.3** binary. Root cause of the live load failure was the package's
deliberately narrow `<2.1.0` version guard, not a removed hook: OpenCode 2.1's
breaking session-schema change (`providerState` → `native`) is not used by
this plugin, and `model.request`, the HTTP/WebSocket guards, `retry`, session
events, RPC, and both entrypoints behave as before. The real TUI smoke test now
loads the packed plugin and runs `/offpeak status` on 2.1.3; the full
deterministic suite passes.

### Tests

- 134 deterministic tests, including a real OpenCode 2.1.3 TUI load and
`/offpeak status`, the packed-install manifest contract, and the new pricing
RPC (canonical PEAK answer, policy id, next transition, input validation).

## 0.1.10 - 2026-10-16

- **Session-scoped off-peak protection (`/offpeak session on|off`).** One interactive session can now deliberately run through a peak window while every other session stays protected. `/offpeak session off` disables off-peak protection for the current session only: its verified peak-priced requests (primary, compaction, title, and generate alike) run immediately, and a request it already has waiting for off-peak is released on the spot through the existing engine signal — no `continue` prompt, no re-sent message, no duplicate provider call, no polling loop. `/offpeak session on` restores normal waiting. The default is ON for every session, and only explicit overrides are stored.
Expand Down
25 changes: 25 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,28 @@ Sometimes one interactive session must deliberately run through a peak window wh
The override is deliberately narrow. It authorizes only requests that evaluate `PEAK` under a verified policy, and only for that one interactive session: `UNKNOWN` and invalid configuration still fail closed, `OFF_PEAK`/`FLAT`/`UNTRACKED` behavior is unchanged, queue-owned sessions never inherit it (a task running in the same OpenCode session still obeys its own task semantics and `/offpeak run-now`), and no other session changes. It covers the session's native lifecycle work (primary, compaction, title, generate) because the user authorized the session, not a request kind. It is checked after every fail-closed guard, so it cannot turn an unverified model into an allowed request.

Two collision points were defined explicitly rather than left implicit. First, a hold that is already waiting must wake immediately: the hold loop re-reads the override after every signal wake-up, and `setSessionProtection` notifies the existing signal and pump, so the same parked request commits with no re-send, no retry, no duplicate provider call, and no new polling, watchdog, or timer; persisted recovery entries for that session are released on the same pass in native order. A refused hold (v0.1.7/v0.1.8 `cancelHold`) is handled before the override check and is never resurrected, and a committed dispatch is never recalled. Second, global precedence: `/offpeak off` remains passive automation (it pauses queue starts and auto-resume, releases nothing, and blocks every session and task), but the explicit per-session grant is newer and more specific, so it applies in `auto`/`queue`/`guard` modes and even while the plugin is globally OFF — for that one session only. This is a deliberate, documented choice in the same category as an explicit `run-now` authorization, not a silent redefinition of `/offpeak off`. Evidence: 19 new deterministic tests (134 total) for the full matrix, and real OpenCode 2.0.30 TUI fixture runs where a held request releases on the spot with exactly one provider call before the boundary, a second session keeps waiting through the same peak window, the override survives a reload and a full server restart, and compaction runs immediately under the override with no pending marker while compact-first behavior with protection on is unchanged.

## OpenCode 2.1 compatibility and the canonical pricing RPC (v0.2.0)

The live host was auto-upgraded from OpenCode 2.0.30 to 2.1.3 and this package
refused to load because the declared range was `>=2.0.26 <2.1.0`. The failure was
the guard itself, not a missing runtime contract: the 2.1.0 breaking change
renamed the session/step payload's `providerState` to `native`, and this plugin
does not read that field. It relies on `model.request`, `http.request` /
`http.response`, the experimental WebSocket hooks, `retry`, `session.execution.*`
and `session.model.selected` events, model transforms, and RPC — all unchanged in
shape and order. The range is therefore extended to `>=2.0.26 <2.2.0` only after
the real TUI and the full free-first coexistence suites pass on 2.1.3. A new
minor line is not claimed on version-number compatibility alone: the guard moves
with tested evidence.

The same release adds a read-only `pricing` RPC that delegates directly to
`evaluatePricing`. `opencode-free-first` needs to know whether DeepSeek Go is in
PEAK before deciding whether Free may be spent. The alternatives were to import
this plugin (couples lifecycles and packaging), copy the weekly windows and
holiday calendar (two authorities that drift), or scrape status text (unstable).
A small RPC is the same pattern as the existing `cancelHold` capability: a
generic, side-effect-free interface on the existing OpenCode plugin RPC channel,
with no new service, queue, timer, or state. The method returns exactly the
canonical answer, including `nextTransition`, so the consumer can cache until the
boundary rather than poll.
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing w

`opencode-offpeak` is an OpenCode CLI v2 plugin. Its default policy covers DeepSeek models through OpenCode Go. Pricing is enforced at two lifecycle points: a pre-model deferral and the outbound transport guard. In `auto` mode, a supported request that reaches peak pricing waits at the pre-model hook — before OpenCode creates the provider attempt and its response-header timer — and the same session continues the same turn automatically when off-peak pricing returns, with zero provider traffic while pricing is blocked. The transport guard remains the final hard barrier immediately before OpenCode sends an HTTP request or an experimental WebSocket frame. If a request is already in flight and fails with a retryable provider/network error after peak pricing begins, the retry is stopped before it can reach the provider and the same session continues automatically at the next off-peak window. When one session must deliberately run through a peak window, `/offpeak session off` authorizes verified peak-priced inference for that session alone; every other session stays protected.

> **Compatibility:** OpenCode CLI `>=2.0.26 <2.1.0`. This release targets the v2 plugin API shipped with OpenCode 2.0.26 and is validated against OpenCode 2.0.26, 2.0.27, and 2.0.30. The package declares the range in `engines.opencode` and both entrypoints refuse to initialize on an unsupported host with an actionable error instead of failing later at runtime. Later API versions are not claimed until CI and runtime checks cover them.
> **Compatibility:** OpenCode CLI `>=2.0.26 <2.2.0`. This release targets the v2 plugin API shipped with OpenCode 2.0.26 and is validated against OpenCode 2.0.26, 2.0.27, 2.0.30, and the current production **2.1.3**. OpenCode 2.1 changed the session/compaction payload schema (`providerState` → `native`) but not the plugin hooks this package uses; the real TUI and headless coexistence suites run on 2.1.3. The package declares the range in `engines.opencode` and both entrypoints refuse to initialize on an unsupported host with an actionable error instead of failing later at runtime.

## Install

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

This installs the released `v0.1.10` 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.2.0` 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 @@ -84,6 +84,24 @@ The refusal is deliberately boring: the outgoing request is replaced with a plac

A refusal produced this way is not treated as a provider failure, so the peak-aware retry recovery does not register a continuation for it — the caller that asked for the cancellation owns what happens next, and no work is ever resumed twice.

### Canonical pricing for other plugins (`pricing` RPC)

Another plugin may need to know whether a model is in a verified PEAK window
without copying this plugin's schedule or holiday calendar. The read-only
`pricing` RPC is the canonical answer:

```
rpc.opencode-offpeak.pricing { providerID, modelID }
-> { state: "PEAK" | "OFF_PEAK" | "FLAT" | "UNTRACKED" | "UNKNOWN",
allowed: boolean, policyID?, nextTransition?, reason? }
```

It evaluates the same configured policies and clock as the guard and has no side
effects: it never queues, holds, blocks, or sends a request. `opencode-free-first`
`v0.2.0` uses it for `freeUsageMode: "peak-only"`; an unavailable RPC or an
unrecognized state is treated as not-PEAK by the consumer. The method does not
create a second pricing engine or expose any mutable state.

### Session-scoped off-peak protection (`/offpeak session on|off`)

Sometimes one session should deliberately run through a peak window while every other session stays protected:
Expand Down
15 changes: 12 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@

## Current public distribution

`v0.1.10` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2:
`v0.2.0` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2:

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

`v0.1.0` through `v0.1.9` remain available at their own tags, but `v0.1.10` is the canonical release: it adds session-scoped off-peak protection (`/offpeak session on|off` — an explicit, persistent, timer-free authorization for one interactive session to use verified peak-priced inference while every other session stays protected; the override wakes already-held work immediately, survives reloads and restarts, never touches queue tasks, and still fails closed for `UNKNOWN` and invalid configuration). This sits on top of the v0.1.9 pending-compaction recovery, the v0.1.7/v0.1.8 hold-cancellation work, the v0.1.6 direct DeepSeek API/verified Zen/held-request feedback, the v0.1.5 title-recovery work, the v0.1.4 pre-model peak deferral and Chinese holiday policy, the v0.1.3 retry recovery, the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used.
`v0.1.0` through `v0.1.10` remain available at their own tags. `v0.2.0` is the
canonical release: it extends the tested OpenCode range through 2.1.3 and adds
the read-only canonical `pricing` RPC consumed by `opencode-free-first`
`peak-only`. The 2.1.3 validation covers the real TUI load and `/offpeak status`,
the full deterministic suite, and the two-plugin coexistence suite. OpenCode
officially accepts npm-compatible Git package specifications, including GitHub
URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)).
This install path loads both the server and TUI entrypoints. npm publication is
optional future distribution; npm login, 2FA, or registry publication is not
required before this release can be installed or used.

## Optional npm distribution

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Current: 0.1.x

- OpenCode CLI v2.0.26 API adapter.
- OpenCode CLI v2.0.26 API adapter; compatibility extended through 2.1.3 (v0.2.0).
- DeepSeek via OpenCode Go policy.
- Persistent sequential queue, ON/OFF, status/list/cancel/retry/resume controls.
- Request-boundary HTTP and experimental WebSocket guard.
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Requirements

- Node.js 22 or newer for the TypeScript/build/test toolchain.
- OpenCode CLI `>=2.0.26 <2.1.0` for runtime integration.
- OpenCode CLI `>=2.0.26 <2.2.0` for runtime integration.
- npm for install/build/pack checks.

The plugin API dependency is pinned to `@opencode/plugin@2.0.26`, matching the minimum supported CLI version. OpenCode's generic plugin documentation still describes its older hook API, so changes to the v2 package/runtime are treated as compatibility changes and must be verified against the installed v2 CLI.
Expand Down
Loading
Loading