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 @@ -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 and 2.0.27; 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.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.

```mermaid
flowchart LR
Expand All @@ -29,6 +29,8 @@ flowchart LR

The queue is one versioned JSON document in OpenCode plugin storage. Each task stores its ID, prompt, working directory, provider/model, policy ID, state, explicit OpenCode session ID, timestamps, retry metadata, last error, and any expiring override. There is no database or second queue service.

Session-scoped off-peak protection is a second, tiny plugin-storage document (`opencode-offpeak/session-overrides-v1`) that records only the sessions the user explicitly switched off (`protection: "off"` with an update timestamp); the absence of an entry means protection is ON, which is the default for every session. It is deliberately not part of `offpeak.json`: it describes one interactive session's explicit authorization, not an installation setting, and it survives reloads and restarts with the session. OpenCode's supported `session.deleted` event removes an entry when its session is deleted; otherwise stale entries are reaped by bounded lazy cleanup on start-up, session commands, and status reads. Only a confident "session not found" is ever treated as deletion — a transient error keeps the entry. There is no timer, no expiry, and no periodic sweep.

The setting file is `$OPENCODE_CONFIG_DIR/offpeak.json` when `OPENCODE_CONFIG_DIR` is set; otherwise it is `$XDG_CONFIG_HOME/opencode/offpeak.json`, defaulting to `~/.config/opencode/offpeak.json`. `OPENCODE_OFFPEAK_CONFIG` can override the file path. It is canonical for the persistent enabled toggle, execution mode, display timezone, and policy definitions. Commands write it atomically with owner-only permissions. The queue remains in OpenCode storage.

## State machine
Expand Down Expand Up @@ -73,6 +75,14 @@ A session can hold several requests at once (the agent loop and an auxiliary tit

The mechanism is deliberately generic: this plugin exports a capability, not a policy. It knows nothing about who calls it or why, a queue-owned hold is never cancellable this way, and an unused capability changes nothing.

### Session-scoped off-peak protection

`/offpeak session off` records an explicit per-session authorization for verified peak-priced inference; `/offpeak session on` removes it. The TUI resolves the current session from OpenCode's supported command context — `ui.router.current()` (the focused route) first, then the active entry of `ui.tabs.list()` — so the user never types a session ID; on the home screen, where no session is focused, the command refuses instead of guessing.

The override is checked inside the request guard after the fail-closed guards and only for requests that are not queue-owned: a request that evaluates `PEAK` under a verified policy is allowed immediately for that session, and `UNKNOWN`/invalid-configuration requests still fail closed. A hold that is already waiting re-reads the override after every signal wake-up, so switching protection off commits the same parked request through the existing signal — no re-send, no retry, no duplicate provider call, no polling loop. Persisted recovery entries for the session are released on the same pass, keeping the native order (pending compaction, primary continuation, title check); entries of other sessions and other classes are untouched. A refused or committed hold is never recalled, and switching protection back on changes nothing for a request already dispatched — the next request that crosses the guard waits normally again.

Precedence is explicit and documented: the per-session grant is newer and more specific than the global automation state, so it applies in `auto`, `queue`, and `guard` modes and while the plugin is globally OFF — for that one session only. `/offpeak off` itself stays passive (it pauses every queue start and auto-resume, releases nothing, and blocks every other session and every task). Background queue tasks never inherit the override, even when they run in the same OpenCode session; `/offpeak run-now <task-id>` remains the task-scoped authorization. Status reports the current session's protection and, at peak with protection off, renders `Pricing: PEAK` with `Requests: ALLOWED (session override)`.

### 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.
Expand Down Expand Up @@ -126,6 +136,8 @@ Recovery delivery is guarded against live work so it can never duplicate it. `se

`/offpeak run-now <task-id>` is a task-scoped, 15-minute override. It is persisted with an absolute expiry and shown in status while active. OFF pauses its use; it does not create or extend it. Expiry returns the next provider request to normal policy enforcement.

`/offpeak session on|off` is the separate, session-scoped, non-expiring protection switch described above. The two overrides are independent and never converted into one another: a task override never applies to an interactive session, and a session override never applies to a queue task.

## Intentionally outside the architecture

- No second agent runtime, external scheduler, helper daemon, database, provider proxy, telemetry, billing aggregator, or web dashboard.
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 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.
- **Session identification uses OpenCode's supported TUI command context**, never a typed-in session ID and never a private UI internal: the focused route first, then the active session tab. On the home screen (no focused session) the command refuses with a clear message.
- **Persistent until changed; no timer.** The override lives in OpenCode plugin storage (`opencode-offpeak/session-overrides-v1`) and survives TUI restarts, plugin reloads, and full server restarts. OpenCode's supported `session.deleted` event reaps it, and old entries for sessions that no longer exist are removed by bounded lazy cleanup on start-up, session commands, and status reads — never by a periodic loop, and never for a session that still exists.
- **Verified PEAK only.** The override authorizes exactly the requests that evaluate `PEAK` under a verified policy; `UNKNOWN` models and invalid configuration still fail closed, and `OFF_PEAK`, `FLAT`, and `UNTRACKED` behavior is unchanged. It never applies to queue-owned sessions: background tasks keep their own semantics, and `/offpeak run-now <task-id>` remains the task-scoped authorization.
- **Waiting work and pending recovery proceed immediately where semantically safe.** A held primary/compaction/title/generate request of the overridden session is committed by re-reading the override in the hold loop; a lost hold's persisted recovery (deferred primary, retry-failure, pending title, pending compaction) is delivered on the spot, preserving the v0.1.9 order (compaction → primary → title) and at-most-once delivery. Other sessions' recovery entries are never touched.
- **Settled work is never recalled or resurrected.** A committed dispatch and a cancelled/refused hold (`cancelHold`) are unaffected by protection changes; switching protection back on never interrupts an in-flight provider request and the next peak request waits normally again.
- **Precedence is explicit.** `/offpeak session off` is an explicit, session-scoped authorization for verified peak inference, newer and more specific than the global automation state: it applies in `auto`, `queue`, and `guard` modes and while the plugin is globally OFF, for that one session only. `/offpeak off` itself is unchanged and remains passive: it pauses all automation, starts nothing, releases nothing, blocks every session that was not explicitly switched off, and blocks every task.
- **Status.** `/offpeak status` shows `Session protection: ON|OFF` for the current session, and while peak is active with protection off it renders `Pricing: PEAK` / `Requests: ALLOWED (session override)` instead of pretending pricing is off-peak.
- Evidence: 134/134 tests (`npm run lint`, `npm run typecheck`, `npm test`) — all 115 prior tests unchanged, plus 19 new deterministic cases covering default-on, the switch itself, current-session identity, persistence across reload/restart, held-request wake with exactly one provider call, multi-session isolation, queue-task separation, queue/guard mode behavior, global-OFF precedence, recovery classes and ordering, cancelHold/refusal interaction, committed-race behavior, compaction regression, and stale-state cleanup. Real OpenCode 2.0.30 TUI + local fixture runs (zero paid traffic) prove: hold → `/offpeak session off` → immediate release in the same session with exactly one provider request before the boundary; a second session remaining held while the first was overridden; `/offpeak session on` → the next request waits again; the override surviving `opencode reload` and a full server restart; and compaction running immediately under the override with no pending-compaction marker while compact-first behavior is unchanged with protection on.

## 0.1.9 - 2026-10-16

- **A compaction held for peak pricing is no longer lost when its hold dies.** Manual compact-first is a normal workflow: compact the session, then send the next prompt. During PEAK that compaction waits at the pre-model hook like any other request — but its hold was memory-only, so a plugin reload, a server restart, or the inactivity sweep ended it with a terminal failed compaction and, worse, left the prompt queued behind it stranded: nothing resumed at off-peak and the compaction never ran. A held compaction now persists a semantic `pending-compaction` recovery entry (with `origin: "pending-compaction"`; the raw provider request is never replayed). As soon as the recovered session is settled and idle, the plugin admits OpenCode's own compaction again; the runner keeps a pending compaction ahead of queued prompts so compact-first order survives, and the re-triggered request holds at the pre-model guard through the peak window exactly like the original. A user cancel clears the marker; inactivity and supersede keep it.
Expand Down
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,11 @@ A request that starts during OFF_PEAK can cross into PEAK and then fail with a r
## 2026-10-16: A held compaction is recovered semantically, admitted as soon as the session is settled

Manual compact-first — compact the session, then send the next prompt — is a normal workflow, and during PEAK the compaction waits at the pre-model hook like any other request. That hold was memory-only, so a plugin reload, a full server restart, or the 60-minute inactivity sweep ended it as a terminal failed compaction and left the prompt queued behind it stranded; nothing resumed at the off-peak window. The plugin now persists a `pending-compaction` marker for a held compaction (manual and automatic compaction are the same `model.request` kind, so both are covered) and recovers it by admitting OpenCode's own compaction again — never by replaying the provider request. Three source facts shaped the design. First, `session.compact` is the supported semantic trigger, and OpenCode's runner keeps a pending compaction ahead of queued prompts (`packages/core/src/session/inbox.ts` reorders earlier steers behind a pending compaction "so their text stays verbatim after the checkpoint"), so the recovered compaction lands before the prompt that waited behind it. Second, the re-triggered request holds at the same pre-model guard, so recovery during peak still produces zero provider traffic. Third, because of that native priority there is no reason to wait for the allowed window to admit the recovery: it is delivered as soon as the session is settled and idle (any settled outcome, including `succeeded` — unlike a primary continuation, a requested compaction that never ran is not made stale by another completed turn), which is what restores compact-first order after a restart instead of letting a later prompt win the inbox. When a primary continuation coexists with a recovered compaction for one session, both are admitted in the same pass in native order (compaction, then continuation) so the continuation is not dropped by the compaction's own success. The v0.1.7/v0.1.8 transport guard cleared primary recoveries for any non-title kind, which could have let a compact request erase a primary deferral; each request kind now clears only its own recovery class. Evidence: deterministic tests for the exact workflow, reload/restart/inactivity/user-cancel semantics, three-class coexistence, class independence, at-most-once delivery and ordering, and real-CLI fixture runs where a restart during a held compaction still ends with exactly one completed compaction, the prompt behind it running once, and clean recovery state.

## 2026-10-16: Session-scoped off-peak protection is an explicit per-session authorization

Sometimes one interactive session must deliberately run through a peak window while every other session stays protected — for example a long-running refactor that should not pause for three hours. The plugin therefore adds `/offpeak session on|off`: an explicit switch for the current session only, defaulting to ON everywhere, with no timer, no expiry, and no duration syntax. The override lives in OpenCode plugin storage (`opencode-offpeak/session-overrides-v1`) as the absence/presence of one entry per session, so it survives TUI restarts, plugin reloads, and server restarts without touching `offpeak.json` (an override belongs to a session, not to the installation). OpenCode's supported `session.deleted` event removes the entry; stale entries are also reaped by bounded lazy cleanup on start-up, session commands, and status reads. Cleanup only trusts a confident "session not found" signal — a transient error must never delete the state of a session that still exists. The current session is resolved from OpenCode's supported TUI command context (`ui.router.current()`, then the active tab); on the home screen, which has no focused session, the command refuses rather than guessing.

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.
Loading
Loading