From de249c83c85a06496537ccc5d41c153957152174 Mon Sep 17 00:00:00 2001 From: Lutz Kind <132444134+lutzkind@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:12:31 +0000 Subject: [PATCH 1/2] feat: session-scoped off-peak protection on/off for the current session /offpeak session off disables off-peak protection for the current interactive OpenCode session only; /offpeak session on restores it. Default is ON for every session; the explicit override persists in OpenCode plugin storage until changed or the session is deleted (no timer, no expiry, no second store). - src/store.ts: SessionOverridesRepository (opencode-offpeak/session-overrides-v1) - src/engine.ts: setSessionProtection/sessionProtection/forgetSession plus bounded lazy pruning on start, session commands, status and the supported session.deleted event; verified-PEAK requests of an overridden session are allowed immediately, a held request wakes through the existing signal, and the same rule releases deferred/retry/pending-title/pending-compaction recoveries for that session without clobbering other classes or sessions. Queue-owned sessions never inherit the override, UNKNOWN and invalid configuration still fail closed, and committed or refused holds are never recalled or resurrected. - src/server.ts: session-off/session-on RPC actions, status sessionID, session.deleted cleanup, explicit not-found errors for missing sessions. - src/tui.ts: /offpeak session on|off resolved from the supported TUI route (ui.router first, active tab fallback), status shows Session protection and 'Requests: ALLOWED (session override)' while PEAK, resume kind override. - tests: 19 new deterministic cases (134 total) covering the full matrix. --- src/engine.ts | 232 +++++++- src/index.ts | 9 +- src/rpc.ts | 5 +- src/server.ts | 49 +- src/store.ts | 58 ++ src/tui.ts | 55 +- test/plugin-entries.test.mjs | 99 +++- test/session-override.test.mjs | 950 +++++++++++++++++++++++++++++++++ 8 files changed, 1437 insertions(+), 20 deletions(-) create mode 100644 test/session-override.test.mjs diff --git a/src/engine.ts b/src/engine.ts index d95055d..dfc00b8 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -15,6 +15,9 @@ import { type RecoveryEntry, type RecoveryOrigin, type RecoveryRepository, + type SessionOverridesDocument, + type SessionOverridesRepository, + type SessionProtectionOverride, type TaskModel, type TaskState, } from "./store.js"; @@ -57,6 +60,8 @@ export interface EngineStatus { automaticExecution: "ACTIVE" | "PAUSED"; automaticResume: "ACTIVE" | "PAUSED"; override: { active: boolean; taskID?: string; expiresAt?: string }; + /** Off-peak protection for the queried session ("ON" by default). */ + sessionProtection?: "ON" | "OFF"; providerID?: string; modelID?: string; policyID?: string; @@ -225,7 +230,30 @@ export type EngineNotification = /** Preformatted deadline ("17:00 Asia/Bangkok") for the UI. */ display?: string; } - | { type: "resume"; sessionID: string; kind: "release" | "continuation" }; + | { + type: "resume"; + sessionID: string; + /** + * `release` — the allowed pricing window arrived normally. + * `continuation` — a persisted recovery continued the session. + * `override` — the user switched this session's off-peak protection OFF, + * releasing its still-valid waiting work at PEAK rates. + */ + kind: "release" | "continuation" | "override"; + }; + +/** + * True when OpenCode reports that a session does not exist: the supported + * client's typed `SessionNotFoundError`, or a host-specific equivalent + * message. Transient errors must never be mistaken for a deleted session, + * because an override for a session that still exists must be kept. + */ +function isSessionNotFound(error: unknown): boolean { + if (error === null || typeof error !== "object") return false; + if ((error as { _tag?: unknown })._tag === "SessionNotFoundError") return true; + const message = (error as { message?: unknown }).message; + return typeof message === "string" && /\bsession not found\b|\bno such session\b/i.test(message); +} export class PricingBlockedError extends Error { readonly code = "OFFPEAK_PRICING_BLOCKED"; @@ -242,6 +270,8 @@ export interface EngineOptions { sessions: SessionDriver; /** Optional persisted recovery metadata for interactive sessions. */ recoveries?: RecoveryRepository; + /** Optional persisted per-session protection overrides. */ + sessionOverrides?: SessionOverridesRepository; /** Plugin instance location; recovery entries are scoped to it. */ location?: string; clock?: EngineClock; @@ -308,6 +338,12 @@ export class OffpeakEngine { */ readonly #running = new Set(); readonly #recoveriesRepo: RecoveryRepository | undefined; + /** + * Session-scoped protection overrides, keyed by session ID. Only explicit + * "off" entries are stored; a missing key means protection is ON (default). + */ + readonly #sessionProtections = new Map(); + readonly #sessionOverridesRepo: SessionOverridesRepository | undefined; readonly #location: string; /** * Recovery entries keyed by `${origin}:${sessionID}`. A session can hold a @@ -330,6 +366,7 @@ export class OffpeakEngine { this.#config = options.config; this.#sessions = options.sessions; this.#recoveriesRepo = options.recoveries; + this.#sessionOverridesRepo = options.sessionOverrides; this.#location = options.location ?? ""; this.#clock = options.clock ?? systemClock; this.#signal = new AsyncSignal(this.#clock); @@ -344,11 +381,21 @@ export class OffpeakEngine { } catch (error) { this.#onError(error); } + try { + await this.#loadSessionProtections(); + } catch (error) { + this.#onError(error); + } try { await this.#reconcileAfterLoad(); } catch (error) { this.#onError(error); } + try { + await this.pruneSessionProtections(); + } catch (error) { + this.#onError(error); + } try { mkdirSync(path.dirname(this.#config.path), { recursive: true, mode: 0o700 }); this.#watcher = watch(path.dirname(this.#config.path), (_event, filename) => { @@ -367,6 +414,7 @@ export class OffpeakEngine { this.#holds.clear(); this.#refusals.clear(); this.#consumedRefusals.clear(); + this.#sessionProtections.clear(); this.#watcher?.close(); if (this.#timer) this.#clock.clearTimeout(this.#timer); if (this.#recoveryTimer) this.#clock.clearTimeout(this.#recoveryTimer); @@ -383,6 +431,79 @@ export class OffpeakEngine { if (enabled) this.#requestPump(); } + /** + * Sets off-peak protection for one interactive session. + * + * `"off"` is an explicit, session-scoped authorization for verified + * PEAK-priced inference in that one session: its peak-priced requests are + * allowed immediately (including native auxiliary lifecycle work such as + * compaction, title and generate), and any still-valid work it is waiting + * with is released on the spot. It never applies to queue-owned sessions, + * never authorizes an UNKNOWN or invalid-policy request, and never changes + * any other session. `"on"` restores the default protection. + * + * The override persists in OpenCode plugin storage until it is changed or + * the session is deleted; there is no timer and no expiry. + */ + async setSessionProtection(sessionID: string, protection: "on" | "off"): Promise<"ON" | "OFF"> { + const id = sessionID.trim(); + if (!id) throw new Error("A session ID is required for a session protection change."); + const had = this.#sessionProtections.has(id); + if (protection === "off") { + // Refresh the timestamp but keep a single authoritative entry. + this.#sessionProtections.set(id, { + protection: "off", + updatedAt: new Date(this.#clock.now()).toISOString(), + }); + } else { + this.#sessionProtections.delete(id); + } + if ((protection === "off") !== had) await this.#persistSessionProtections(); + // Wake held requests and recovery delivery so a switch to OFF releases + // still-valid waiting work immediately; a switch to ON simply restores the + // normal waiting behavior for future requests. + this.#signal.notify(); + this.#requestPump(); + return protection === "off" ? "OFF" : "ON"; + } + + /** Off-peak protection for one session; absence means ON (the default). */ + sessionProtection(sessionID: string): "ON" | "OFF" { + return this.#sessionProtections.has(sessionID) ? "OFF" : "ON"; + } + + /** + * Forgets every explicit override for a session that no longer exists. + * Called from OpenCode's supported `session.deleted` event; never removes + * state for a session that still exists. + */ + async forgetSession(sessionID: string): Promise { + if (!this.#sessionProtections.delete(sessionID)) return; + await this.#persistSessionProtections(); + } + + /** + * Bounded lazy cleanup for overrides whose session no longer exists. Runs on + * start-up and explicit session commands, never on a timer, and never removes + * an override for a session that still exists. + */ + async pruneSessionProtections(): Promise { + if (this.#sessionProtections.size === 0) return; + let changed = false; + for (const sessionID of [...this.#sessionProtections.keys()]) { + try { + await this.#sessions.get(sessionID); + } catch (error) { + // Only a confident not-found may reap an override; a transient error + // means the session may still exist and its state must be kept. + if (isSessionNotFound(error) && this.#sessionProtections.delete(sessionID)) { + changed = true; + } + } + } + if (changed) await this.#persistSessionProtections(); + } + async queue(input: { prompt: string; cwd: string; model: TaskModel }): Promise { const prompt = input.prompt.trim(); const cwd = input.cwd.trim(); @@ -504,7 +625,12 @@ export class OffpeakEngine { this.#requestPump(); } - async status(current?: { providerID?: string; modelID?: string }): Promise { + async status(current?: { + providerID?: string; + modelID?: string; + sessionID?: string; + }): Promise { + if (current?.sessionID !== undefined) await this.#pruneSessionOverride(current.sessionID); const settings = await this.#config.read(); const tasks = await this.list(); const selected = @@ -528,6 +654,8 @@ export class OffpeakEngine { ); const enabled = settings.config?.enabled ?? false; const providerNote = selected?.providerID ? flatProviderNote(selected.providerID) : undefined; + const sessionProtection = + current?.sessionID !== undefined ? this.sessionProtection(current.sessionID) : undefined; return { enabled, mode: settings.config?.mode ?? "auto", @@ -536,6 +664,7 @@ export class OffpeakEngine { override: override?.overrideUntil ? { active: true, taskID: override.id, expiresAt: override.overrideUntil } : { active: false }, + ...(sessionProtection ? { sessionProtection } : {}), ...(selected?.providerID ? { providerID: selected.providerID } : {}), ...(selected?.modelID ? { modelID: selected.modelID } : {}), ...(decision.policy?.id ? { policyID: decision.policy.id } : {}), @@ -543,7 +672,7 @@ export class OffpeakEngine { requestsAllowed: (!enabled && running !== undefined) || decision.state === "UNKNOWN" ? false - : decision.allowed, + : decision.allowed || (sessionProtection === "OFF" && decision.state === "PEAK"), ...(decision.nextTransition !== undefined ? { nextTransition: decision.nextTransition } : {}), ...(providerNote && decision.state === "UNTRACKED" ? { providerNote } : {}), displayTimeZone: settings.config?.displayTimeZone ?? "UTC", @@ -649,6 +778,17 @@ export class OffpeakEngine { if (decision.state === "UNKNOWN") { throw new PricingBlockedError(`Offpeak pricing guard failed closed: ${decision.reason}`); } + // An explicit session-scoped override: verified PEAK pricing is authorized + // for this interactive session only. It is checked after the UNKNOWN + // fail-closed guard and after the config error guard, so it can never turn + // an unverified model or an invalid configuration into an allowed request, + // and it never applies to a queue-owned session (task !== undefined). + if ( + task === undefined && + decision.state === "PEAK" && + this.#sessionProtectionOff(input.sessionID) + ) + return; if (this.#hasOverride(task, this.#clock.now()) && first.config.enabled) return; if (decision.allowed && (!task || first.config.enabled)) return; @@ -749,6 +889,21 @@ export class OffpeakEngine { if (task) await this.#setTaskError(task.id, decision.reason); throw new PricingBlockedError(`Offpeak pricing guard failed closed: ${decision.reason}`); } + // The user switched this interactive session's protection off while it + // was waiting: the verified PEAK request is authorized now, so the hold + // commits and the same request continues — no retry, no re-send, and no + // duplicate provider call. A refused hold is handled above and is never + // resurrected by this path. + if ( + task === undefined && + decision.state === "PEAK" && + this.#sessionProtectionOff(input.sessionID) + ) { + if (hold !== undefined) hold.state = "committed"; + if (deferred) await this.#removeRecovery(input.sessionID, [deferralOrigin]); + this.#announceResume(input.sessionID, "override"); + return; + } if ( task && settings.config.enabled && @@ -995,11 +1150,12 @@ export class OffpeakEngine { /** * Announces the release of a hold that this instance announced. A hold that * was interrupted, cancelled, or transferred to a recovery continuation - * produces no release notification here. + * produces no release notification here. `override` says the release came + * from `/offpeak session off` while pricing is still PEAK. */ - #announceResume(sessionID: string): void { + #announceResume(sessionID: string, kind: "release" | "override" = "release"): void { if (!this.#announcedHolds.delete(sessionID)) return; - this.#notify({ type: "resume", sessionID, kind: "release" }); + this.#notify({ type: "resume", sessionID, kind }); } #registerInteractiveWaiter(): number { @@ -1057,6 +1213,10 @@ export class OffpeakEngine { this.#requestPump(); return "hold"; } + // This session is explicitly operating at verified PEAK rates: a retryable + // provider failure is nothing more than that, so OpenCode's normal retry + // behavior applies and no recovery continuation is registered. + if (this.#sessionProtectionOff(input.sessionID)) return "pass"; await this.#registerRecovery(input.sessionID, input.providerID, input.modelID, "retry-failure"); this.#signal.notify(); this.#requestPump(); @@ -1111,6 +1271,46 @@ export class OffpeakEngine { } } + async #loadSessionProtections(): Promise { + const repo = this.#sessionOverridesRepo; + if (!repo) return; + const document = await repo.load(); + this.#sessionProtections.clear(); + for (const [sessionID, override] of Object.entries(document.sessions)) { + if (override.protection === "off") this.#sessionProtections.set(sessionID, override); + } + } + + async #persistSessionProtections(): Promise { + const repo = this.#sessionOverridesRepo; + if (!repo) return; + const document: SessionOverridesDocument = { + schemaVersion: 1, + sessions: Object.fromEntries( + [...this.#sessionProtections.entries()].map(([sessionID, override]) => [ + sessionID, + { ...override }, + ]), + ), + }; + await this.#lock.run(() => repo.save(document)); + } + + /** True when the user explicitly switched this session's protection off. */ + #sessionProtectionOff(sessionID: string): boolean { + return this.#sessionProtections.has(sessionID); + } + + /** Removes the override for one session that no longer exists (lazy). */ + async #pruneSessionOverride(sessionID: string): Promise { + if (!this.#sessionProtections.has(sessionID)) return; + try { + await this.#sessions.get(sessionID); + } catch (error) { + if (isSessionNotFound(error)) await this.forgetSession(sessionID); + } + } + #recoveryKey(entry: Pick): string { return `${entry.origin ?? "legacy"}\u0000${entry.sessionID}`; } @@ -1218,9 +1418,12 @@ export class OffpeakEngine { // runner keeps a pending compaction ahead of queued prompts, so // compact-first ordering survives without waiting for the boundary. In // queue/guard modes a re-trigger during peak would fail closed, so it - // waits for the allowed window. + // waits for the allowed window — unless this session's protection was + // explicitly switched off, in which case its pending compaction proceeds + // immediately at the authorized peak rates. const deliverable = - settings.config.enabled && (settings.config.mode === "auto" || decision.allowed); + (settings.config.enabled && (settings.config.mode === "auto" || decision.allowed)) || + this.#sessionProtectionOff(entry.sessionID); if (deliverable) { const result = await this.#deliverCompactionRecovery(entry); if (result.retryAt !== undefined) @@ -1243,9 +1446,13 @@ export class OffpeakEngine { // A continuation that belongs with a compaction admitted in this pass is // delivered with it: the runner keeps the compaction ahead, so the // continuation waits behind the checkpoint while the peak window holds - // both. + // both. A session whose protection is explicitly off proceeds + // immediately. const forced = compactionAdmitted.has(entry.sessionID); - if (settings.config.enabled && (forced || decision.allowed)) { + if ( + (settings.config.enabled && (forced || decision.allowed)) || + this.#sessionProtectionOff(entry.sessionID) + ) { const retryAt = await this.#deliverRecovery(entry, { force: forced }); if (retryAt !== undefined) next = next === undefined ? retryAt : Math.min(next, retryAt); continue; @@ -1262,7 +1469,10 @@ export class OffpeakEngine { entry.modelID, this.#clock.now(), ); - if (settings.config.enabled && decision.allowed) { + if ( + (settings.config.enabled && decision.allowed) || + this.#sessionProtectionOff(entry.sessionID) + ) { const retryAt = await this.#deliverTitleRecovery(entry); if (retryAt !== undefined) next = next === undefined ? retryAt : Math.min(next, retryAt); continue; diff --git a/src/index.ts b/src/index.ts index 33599a9..1a9ff00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,14 @@ export type { RecoveryDocument, RecoveryEntry, RecoveryOrigin, + SessionOverridesDocument, + SessionProtectionOverride, TaskModel, TaskState, } from "./store.js"; -export { QueueRepository, RecoveryRepository } from "./store.js"; +export { + QueueRepository, + RecoveryRepository, + SESSION_OVERRIDES_STORAGE_KEY, + SessionOverridesRepository, +} from "./store.js"; diff --git a/src/rpc.ts b/src/rpc.ts index e04212c..11f899e 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -20,6 +20,7 @@ export const OFFPEAK_RPC = { cwd: { type: "string" }, providerID: { type: "string" }, modelID: { type: "string" }, + sessionID: { type: "string" }, }, required: ["action"], additionalProperties: false, @@ -103,7 +104,7 @@ export interface OffpeakHoldEvent { export interface OffpeakResumeEvent { sessionID: string; - kind?: "release" | "continuation"; + kind?: "release" | "continuation" | "override"; } export interface OffpeakRpcInput { @@ -113,6 +114,8 @@ export interface OffpeakRpcInput { cwd?: string; providerID?: string; modelID?: string; + /** Current interactive session, for session-scoped protection control. */ + sessionID?: string; } export interface OffpeakRpcOutput { diff --git a/src/server.ts b/src/server.ts index 437db3c..16ec5ae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { refusedRequest, } from "./refused.js"; import { OFFPEAK_RPC, type OffpeakRpcInput, type OffpeakRpcOutput } from "./rpc.js"; -import { QueueRepository, RecoveryRepository } from "./store.js"; +import { QueueRepository, RecoveryRepository, SessionOverridesRepository } from "./store.js"; export default Plugin.define({ id: "opencode-offpeak", @@ -23,6 +23,7 @@ export default Plugin.define({ }; const repository = new QueueRepository(storage); const recoveries = new RecoveryRepository(storage); + const sessionOverrides = new SessionOverridesRepository(storage); const sessions: SessionDriver = { create: async ({ cwd, model, taskID }) => { const session = await context.session.create({ @@ -39,6 +40,15 @@ export default Plugin.define({ wait: async (sessionID) => context.session.wait({ sessionID }), get: async (sessionID) => { const session = await context.session.get({ sessionID }); + // A missing session (or a host that returns undefined instead of + // throwing) is reported explicitly, so state cleanup can distinguish it + // from a transient error and only reap sessions that no longer exist. + if (!session) { + const error = new Error(`Session not found: ${sessionID}`); + error.name = "SessionNotFoundError"; + Object.assign(error, { _tag: "SessionNotFoundError" }); + throw error; + } // The wire format carries millis; the typed client exposes a DateTime. const created = (session.time as { created?: unknown } | undefined)?.created; const createdMs = typeof created === "number" ? created : Date.parse(String(created)); @@ -71,6 +81,7 @@ export default Plugin.define({ const engine = new OffpeakEngine({ repository, recoveries, + sessionOverrides, location: context.location?.directory ?? "", config, sessions, @@ -198,6 +209,14 @@ export default Plugin.define({ sessionID: event.data.sessionID, reason: event.data.reason, }); + continue; + } + // OpenCode's supported session-deletion event: an explicit per-session + // protection override for a session that no longer exists is reaped + // here instead of lingering in plugin storage. State for a session + // that still exists is never removed. + if (event.type === "session.deleted") { + await engine.forgetSession(event.data.sessionID); } } } catch (error) { @@ -218,8 +237,31 @@ export default Plugin.define({ await engine.setEnabled(false); return { message: - "OpenCode Offpeak is OFF. Automation is paused; queued tasks and session IDs are preserved. Peak pricing is still blocked.", + "OpenCode Offpeak is OFF. Automation is paused; queued tasks and session IDs are preserved. Peak pricing is still blocked except for sessions you explicitly switched with /offpeak session off.", }; + case "session-on": + case "session-off": { + const sessionID = input.sessionID?.trim(); + if (!sessionID) { + throw new Error( + "No current OpenCode session was provided. Run /offpeak session on|off from an open session.", + ); + } + const protection = await engine.setSessionProtection( + sessionID, + input.action === "session-off" ? "off" : "on", + ); + // Lazy, bounded cleanup: a session command is a natural state + // access, so stale overrides (deleted sessions) are reaped here. + void engine.pruneSessionProtections().catch(() => undefined); + return { + message: + protection === "OFF" + ? "Session off-peak protection disabled. This session may use verified peak-priced inference." + : "Session off-peak protection enabled. Peak-priced requests will wait for off-peak.", + state: protection, + }; + } case "queue": { if (!input.prompt) throw new Error("Usage: /offpeak queue "); if (!input.providerID || !input.modelID) { @@ -237,6 +279,7 @@ export default Plugin.define({ const status = await engine.status({ ...(input.providerID ? { providerID: input.providerID } : {}), ...(input.modelID ? { modelID: input.modelID } : {}), + ...(input.sessionID?.trim() ? { sessionID: input.sessionID.trim() } : {}), }); return { status: status as unknown as Record }; } @@ -268,7 +311,7 @@ export default Plugin.define({ return { message: `Explicit 15-minute peak-price override active for ${id}.` }; default: throw new Error( - "Usage: /offpeak on|off|status|pricing|queue|list|cancel|remove|retry|resume|run-now", + "Usage: /offpeak on|off|status|pricing|queue|list|cancel|remove|retry|resume|run-now|session on|session off", ); } }, diff --git a/src/store.ts b/src/store.ts index 4294c62..41a2f31 100644 --- a/src/store.ts +++ b/src/store.ts @@ -72,6 +72,25 @@ export interface RecoveryDocument { entries: RecoveryEntry[]; } +/** + * An explicit, session-scoped override of off-peak protection. Only sessions + * the user deliberately switched off are stored; the absence of an entry means + * protection is ON, which is the default for every session. The override is an + * explicit authorization for verified PEAK-priced inference in that one + * interactive session — it never applies to queue-owned sessions and never + * turns an unverified (UNKNOWN) model into an allowed request. + */ +export interface SessionProtectionOverride { + protection: "off"; + updatedAt: string; +} + +export interface SessionOverridesDocument { + schemaVersion: 1; + /** Keyed by OpenCode session ID. */ + sessions: Record; +} + export interface JsonStorage { get(key: string): Promise; set(key: string, value: unknown): Promise; @@ -79,6 +98,7 @@ export interface JsonStorage { export const QUEUE_STORAGE_KEY = "opencode-offpeak/queue-v1"; export const RECOVERY_STORAGE_KEY = "opencode-offpeak/recovery-v1"; +export const SESSION_OVERRIDES_STORAGE_KEY = "opencode-offpeak/session-overrides-v1"; export function emptyQueue(): QueueDocument { return { schemaVersion: 1, tasks: [] }; @@ -88,6 +108,10 @@ export function emptyRecovery(): RecoveryDocument { return { schemaVersion: 1, entries: [] }; } +export function emptySessionOverrides(): SessionOverridesDocument { + return { schemaVersion: 1, sessions: {} }; +} + export class QueueRepository { constructor(private readonly storage: JsonStorage) {} @@ -124,6 +148,27 @@ export class RecoveryRepository { } } +export class SessionOverridesRepository { + constructor(private readonly storage: JsonStorage) {} + + async load(): Promise { + const value = await this.storage.get(SESSION_OVERRIDES_STORAGE_KEY); + if (value === undefined) return emptySessionOverrides(); + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.sessions)) { + throw new Error("Stored offpeak session overrides are invalid; refusing to overwrite them."); + } + const sessions: Record = {}; + for (const [sessionID, override] of Object.entries(value.sessions)) { + sessions[sessionID] = validateSessionOverride(sessionID, override); + } + return { schemaVersion: 1, sessions }; + } + + async save(document: SessionOverridesDocument): Promise { + await this.storage.set(SESSION_OVERRIDES_STORAGE_KEY, document as unknown as JsonValue); + } +} + export class Mutex { #tail: Promise = Promise.resolve(); @@ -249,4 +294,17 @@ function validateRecoveryEntry(value: unknown, index: number): RecoveryEntry { return value as unknown as RecoveryEntry; } +function validateSessionOverride(sessionID: string, value: unknown): SessionProtectionOverride { + if (!sessionID.trim()) throw new Error("Stored session override has an empty session ID."); + if (!isRecord(value)) + throw new Error(`Stored session override for ${sessionID} is not an object.`); + if (value.protection !== "off") { + throw new Error(`Stored session override for ${sessionID} has an invalid protection value.`); + } + if (typeof value.updatedAt !== "string") { + throw new Error(`Stored session override for ${sessionID} has an invalid update time.`); + } + return { protection: "off", updatedAt: value.updatedAt }; +} + type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; diff --git a/src/tui.ts b/src/tui.ts index 90d26f5..27c2678 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -68,7 +68,9 @@ export default Plugin.define({ message: data.kind === "continuation" ? "Off-peak pricing started. Continuing this session from where it stopped." - : "Off-peak pricing started. Resuming the held request in this session.", + : data.kind === "override" + ? "Session off-peak protection is OFF. Releasing the held request in this session now." + : "Off-peak pricing started. Resuming the held request in this session.", variant: "success", duration: 8000, sessionID: data.sessionID, @@ -88,6 +90,7 @@ async function invoke(context: Plugin.Context, rawInput: string): Promise; } + // Session-scoped off-peak protection for the current interactive session. + // The session is identified from the supported TUI route, never typed by + // hand; the state persists until the user changes it and there is no timer. + if (action === "session") { + const direction = argument.toLowerCase(); + if (direction !== "on" && direction !== "off") { + throw new Error("Usage: /offpeak session on|off"); + } + if (!sessionID) { + throw new Error( + "No current OpenCode session is focused. Open the session and run /offpeak session on|off again.", + ); + } + return client.control({ + action: `session-${direction}`, + sessionID, + }) as Promise; + } if (["cancel", "remove", "retry", "resume", "run-now"].includes(action)) { if (!argument) throw new Error(`Usage: /offpeak ${action} `); return client.control({ action, id: argument }) as Promise; @@ -109,9 +130,33 @@ async function invoke(context: Plugin.Context, rawInput: string): Promise; } +/** + * The current interactive session, resolved from OpenCode's supported TUI + * command context: the focused route first, then the active session tab. The + * user never has to copy a session ID. + */ +function currentSessionID(context: Plugin.Context): string | undefined { + try { + const route = context.ui.router?.current?.(); + if (route?.type === "session" && typeof route.sessionID === "string") { + return route.sessionID; + } + } catch { + // Fall through to the active tab. + } + try { + const active = context.ui.tabs?.list?.().find((tab) => tab.active); + if (active && typeof active.sessionID === "string") return active.sessionID; + } catch { + // No session context is available from this host generation. + } + return undefined; +} + function summarize(output: OffpeakRpcOutput): string { if (output.message) return output.message; if (output.tasks) { @@ -130,6 +175,7 @@ function summarize(output: OffpeakRpcOutput): string { automaticExecution?: string; automaticResume?: string; override?: { active?: boolean; taskID?: string; expiresAt?: string }; + sessionProtection?: string; providerID?: string; modelID?: string; policyID?: string; @@ -148,14 +194,16 @@ function summarize(output: OffpeakRpcOutput): string { configError?: string; }; const pricing = status.pricingState ?? "UNKNOWN"; + const sessionOverride = status.sessionProtection === "OFF" && pricing === "PEAK"; const next = status.nextTransition && status.displayTimeZone ? formatInTimeZone(status.nextTransition, status.displayTimeZone) : pricing === "FLAT" || pricing === "UNTRACKED" ? "none (no peak window)" : "unknown"; - const requestDetail = - pricing === "FLAT" + const requestDetail = sessionOverride + ? " (session override)" + : pricing === "FLAT" ? " (flat all-hours pricing; never waits)" : pricing === "UNTRACKED" ? status.providerNote @@ -173,6 +221,7 @@ function summarize(output: OffpeakRpcOutput): string { `Provider/model: ${status.providerID ?? "unknown"}/${status.modelID ?? "unknown"}`, `Policy: ${status.policyID ?? (pricing === "UNKNOWN" ? "none (model not in any verified policy)" : "none")}`, `Pricing: ${pricing}`, + status.sessionProtection ? `Session protection: ${status.sessionProtection}` : "", `Requests: ${status.requestsAllowed ? "ALLOWED" : "BLOCKED"}${requestDetail}`, `Next transition: ${next}`, `Queue: ${status.queued ?? 0} queued, ${status.waiting ?? 0} waiting, ${status.failed ?? 0} failed`, diff --git a/test/plugin-entries.test.mjs b/test/plugin-entries.test.mjs index bb5695d..5569d2f 100644 --- a/test/plugin-entries.test.mjs +++ b/test/plugin-entries.test.mjs @@ -237,6 +237,70 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp }); assert.equal(afterInactivity.status.recoveryWaiting, 2); + // Session-scoped protection: default ON; the explicit OFF is this one + // session's authorization for verified peak-priced inference, and it also + // releases that session's still-valid waiting work immediately. + const defaultProtection = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + sessionID: "entrypoint-override", + }); + assert.equal(defaultProtection.status.sessionProtection, "ON"); + // The fake host now knows the session exists; lazy pruning must keep state + // for a session that still exists. + sessionStore.set("entrypoint-override", { outcome: undefined, time: { created: Date.now() } }); + const overrideHeld = hooks + .get("http.request")({ + sessionID: "entrypoint-override", + kind: "primary", + model: { providerID: "opencode-go", id: "deepseek-v4.1-flash" }, + request: new Request("http://fixture.invalid"), + }) + .then( + () => "released", + (error) => error, + ); + await assertStillPending(overrideHeld, 60); + const sessionOff = await rpcHandlers.control({ + action: "session-off", + sessionID: "entrypoint-override", + }); + assert.equal(sessionOff.state, "OFF"); + assert.match(sessionOff.message, /Session off-peak protection disabled/); + // The held request wakes immediately, in the same session, at peak rates. + assert.equal(await overrideHeld, "released"); + const overridden = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + sessionID: "entrypoint-override", + }); + assert.equal(overridden.status.sessionProtection, "OFF"); + assert.equal(overridden.status.pricingState, "PEAK"); + assert.equal(overridden.status.requestsAllowed, true); + // Without a session ID the command is refused rather than guessing. + await assert.rejects( + rpcHandlers.control({ action: "session-off" }), + /No current OpenCode session/, + ); + // OpenCode's own session-deletion event reaps the override. + pushEvent({ type: "session.deleted", data: { sessionID: "entrypoint-override" } }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const afterDeletion = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + sessionID: "entrypoint-override", + }); + assert.equal(afterDeletion.status.sessionProtection, "ON"); + const sessionOn = await rpcHandlers.control({ + action: "session-on", + sessionID: "entrypoint-override", + }); + assert.equal(sessionOn.state, "ON"); + assert.match(sessionOn.message, /Session off-peak protection enabled/); + const off = await rpcHandlers.control({ action: "off" }); assert.match(off.message, /Peak pricing is still blocked/); // OFF never releases a held request and is not a price override. @@ -258,7 +322,9 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp modelID: "deepseek-v4.1-flash", }); assert.match(queued.message, /Queued [a-f0-9-]+/); - assert.equal(sessionStore.size, 0); + // No task session was created while the plugin is OFF (the pre-registered + // verification sessions carry no task input). + assert.equal([...sessionStore.values()].filter((session) => session.input).length, 0); const status = await rpcHandlers.control({ action: "status", providerID: "opencode-go", @@ -269,6 +335,8 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp assert.equal(status.status.requestsAllowed, false); assert.equal(status.status.interactiveWaiting, 1); assert.equal(status.status.recoveryWaiting, 2); + // Without a session, no session protection is reported (home screen). + assert.equal(status.status.sessionProtection, undefined); let layer; const eventHandlers = new Map(); @@ -290,6 +358,8 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp location: { directory: "/fixture-project" }, ui: { model: { current: () => ({ providerID: "opencode-go", modelID: "deepseek-v4.1-flash" }) }, + router: { current: () => ({ type: "session", sessionID: "tui-session" }) }, + tabs: { list: () => [] }, toast: { show: (options) => toasts.push(options) }, }, }; @@ -336,6 +406,33 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp await command.run(`/offpeak remove ${tasks[0].id}`); assert.match(toasts.at(-1).message, /Removed/); assert.equal((await rpcHandlers.control({ action: "list" })).tasks.length, 1); + + // Session protection from the real TUI command: the focused session is + // resolved from the supported route, never typed by hand. + sessionStore.set("tui-session", { outcome: undefined, time: { created: Date.now() } }); + await command.run("/offpeak session off"); + assert.match(toasts.at(-1).message, /Session off-peak protection disabled/); + assert.match(toasts.at(-1).message, /verified peak-priced inference/); + const tuiSessionStatus = await rpcHandlers.control({ + action: "status", + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + sessionID: "tui-session", + }); + assert.equal(tuiSessionStatus.status.sessionProtection, "OFF"); + await command.run("/offpeak status"); + assert.match(toasts.at(-1).message, /Session protection: OFF/); + await command.run("/offpeak session on"); + assert.match(toasts.at(-1).message, /Session off-peak protection enabled/); + await command.run("/offpeak status"); + assert.match(toasts.at(-1).message, /Session protection: ON/); + await command.run("/offpeak session sometimes"); + assert.match(toasts.at(-1).message, /Usage: \/offpeak session on\|off/); + // Without a focused session the command refuses instead of guessing. + tuiContext.ui.router.current = () => ({ type: "home" }); + await command.run("/offpeak session off"); + assert.match(toasts.at(-1).message, /No current OpenCode session is focused/); + tuiContext.ui.router.current = () => ({ type: "session", sessionID: "tui-session" }); } finally { await dispose?.(); await rm(root, { recursive: true, force: true }); diff --git a/test/session-override.test.mjs b/test/session-override.test.mjs new file mode 100644 index 0000000..99aa9cc --- /dev/null +++ b/test/session-override.test.mjs @@ -0,0 +1,950 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + ConfigRepository, + OffpeakEngine, + PricingBlockedError, + QueueRepository, + RecoveryRepository, + SESSION_OVERRIDES_STORAGE_KEY, + SessionOverridesRepository, +} from "../dist/index.js"; + +class MemoryStorage { + values = new Map(); + async get(key) { + return structuredClone(this.values.get(key)); + } + async set(key, value) { + this.values.set(key, structuredClone(value)); + } +} + +class FakeClock { + constructor(now) { + this.value = Date.parse(now); + } + now = () => this.value; + setTimeout = (callback, milliseconds) => { + const timer = setTimeout(callback, milliseconds); + timer.unref(); + return timer; + }; + clearTimeout = (timer) => clearTimeout(timer); + set(value) { + this.value = Date.parse(value); + } +} + +class ManualClock { + constructor(now) { + this.value = Date.parse(now); + this.timers = new Set(); + this.sequence = 0; + } + now = () => this.value; + setTimeout = (callback, milliseconds) => { + const timer = { id: ++this.sequence, at: this.value + Math.max(0, milliseconds), callback }; + this.timers.add(timer); + return timer; + }; + clearTimeout = (timer) => { + this.timers.delete(timer); + }; + set(value) { + this.value = Date.parse(value); + } + async advanceTo(value) { + this.value = Date.parse(value); + await this.flush(); + } + async flush() { + for (let round = 0; round < 200; round += 1) { + const due = [...this.timers].filter((timer) => timer.at <= this.value); + if (!due.length) return; + for (const timer of due) { + this.timers.delete(timer); + timer.callback(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("ManualClock did not settle"); + } +} + +class FakeSessions { + next = 0; + prompts = []; + compactions = []; + interrupts = 0; + workers = new Map(); + outcomes = new Map(); + behavior = async () => undefined; + createdTasks = []; + snapshots = new Map(); + missing = new Set(); + async create(input) { + const id = `session-${++this.next}`; + this.createdTasks.push({ id, taskID: input.taskID, cwd: input.cwd, model: input.model }); + this.outcomes.set(id, undefined); + return id; + } + async prompt(sessionID, text) { + this.prompts.push({ sessionID, text }); + const worker = Promise.resolve() + .then(() => this.behavior({ sessionID, text })) + .then((result) => this.outcomes.set(sessionID, result?.outcome ?? "succeeded")) + .catch((error) => { + this.outcomes.set(sessionID, "failed"); + throw error; + }); + this.workers.set(sessionID, worker); + } + async wait(sessionID) { + await this.workers.get(sessionID)?.catch(() => undefined); + } + async get(sessionID) { + if (this.missing.has(sessionID)) { + const error = new Error(`Session not found: ${sessionID}`); + Object.assign(error, { _tag: "SessionNotFoundError" }); + throw error; + } + const outcome = this.outcomes.get(sessionID); + const extra = this.snapshots.get(sessionID) ?? {}; + return { ...(outcome !== undefined ? { outcome } : {}), ...extra }; + } + async interrupt(sessionID) { + this.interrupts += 1; + this.outcomes.set(sessionID, "interrupted"); + } + async compact(sessionID) { + this.compactions.push(sessionID); + } +} + +async function until(predicate, message = "condition was not reached") { + const end = Date.now() + 5000; + while (Date.now() < end) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(message); +} + +async function assertStillPending(outcome, milliseconds = 60) { + const result = await Promise.race([ + outcome.then( + () => "settled", + () => "settled", + ), + new Promise((resolve) => setTimeout(() => resolve("pending"), milliseconds)), + ]); + assert.equal(result, "pending"); +} + +async function harness(now = "2026-10-12T01:30:00Z", options = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-session-test-")); + const clock = options.clock ?? new FakeClock(now); + const storage = options.storage ?? new MemoryStorage(); + const sessions = options.sessions ?? new FakeSessions(); + const config = new ConfigRepository({ + configPath: path.join(root, "offpeak.json"), + ...(options.options ?? {}), + }); + const notifications = options.notifications ?? []; + const engine = new OffpeakEngine({ + repository: new QueueRepository(storage), + recoveries: new RecoveryRepository(storage), + sessionOverrides: new SessionOverridesRepository(storage), + location: "test-location", + config, + sessions, + clock, + id: () => `task-${Math.random().toString(16).slice(2, 8)}`, + notify: (event) => notifications.push(event), + }); + await engine.start(); + return { + root, + clock, + storage, + sessions, + config, + engine, + notifications, + async close() { + await engine.dispose(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** Reconstructs an engine over the same storage (reload / server restart). */ +async function restart(h) { + const engine = new OffpeakEngine({ + repository: new QueueRepository(h.storage), + recoveries: new RecoveryRepository(h.storage), + sessionOverrides: new SessionOverridesRepository(h.storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock: h.clock, + notify: (event) => h.notifications.push(event), + }); + await engine.start(); + return engine; +} + +const selectedModel = { providerID: "opencode-go", modelID: "deepseek-v4.1-flash" }; +const PEAK = "2026-10-12T02:00:00Z"; // Monday 02:00 UTC: inside the verified peak window. +const OFF_PEAK = "2026-10-12T00:30:00Z"; + +test("session protection defaults to ON and OFF_PEAK requests are unchanged", async () => { + const h = await harness(OFF_PEAK); + try { + const status = await h.engine.status({ ...selectedModel, sessionID: "session-a" }); + assert.equal(status.sessionProtection, "ON"); + assert.equal(status.pricingState, "OFF_PEAK"); + assert.equal(status.requestsAllowed, true); + // OFF_PEAK passes with protection ON and OFF alike. + await h.engine.guardRequest({ sessionID: "session-a", ...selectedModel, transport: "http" }); + await h.engine.setSessionProtection("session-a", "off"); + await h.engine.guardRequest({ sessionID: "session-a", ...selectedModel, transport: "http" }); + // FLAT and UNTRACKED providers stay allowed with protection ON. + await h.engine.guardRequest({ + sessionID: "session-a", + providerID: "opencode-go", + modelID: "gpt-6-luna", + transport: "http", + }); + await h.engine.guardRequest({ + sessionID: "session-a", + providerID: "opencode", + modelID: "deepseek-v4.1-flash-free", + transport: "http", + }); + } finally { + await h.close(); + } +}); + +test("PEAK with session protection ON holds; session off allows the same request immediately", async () => { + const h = await harness(PEAK); + let providerCalls = 0; + const sessionID = "session-a"; + try { + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then(() => { + providerCalls += 1; + }); + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + // Protection ON: the request waits and a primary deferral is persisted. + assert.equal(providerCalls, 0); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 1); + + // The user switches this session's protection off; the hold wakes + // immediately, the same request continues, and the deferral is removed. + assert.equal(await h.engine.setSessionProtection(sessionID, "off"), "OFF"); + await held; + assert.equal(providerCalls, 1); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(providerCalls, 1); + const status = await h.engine.status({ ...selectedModel, sessionID }); + assert.equal(status.pricingState, "PEAK"); + assert.equal(status.sessionProtection, "OFF"); + assert.equal(status.requestsAllowed, true); + assert.equal(status.recoveryWaiting, 0); + assert.equal(status.interactiveWaiting, 0); + // No continuation was injected and the session was never interrupted. + assert.equal(h.sessions.prompts.length, 0); + assert.equal(h.sessions.interrupts, 0); + // Exactly one hold notification and one override release. + assert.deepEqual( + h.notifications.map((event) => `${event.type}:${event.kind ?? ""}`), + ["hold:", "resume:override"], + ); + } finally { + await h.close(); + } +}); + +test("session on restores waiting: the next PEAK request holds again", async () => { + const h = await harness(PEAK); + const sessionID = "session-a"; + try { + await h.engine.setSessionProtection(sessionID, "off"); + const first = h.engine.guardModelRequest({ + sessionID, + ...selectedModel, + kind: "primary", + transport: "model", + }); + await first; // Allowed immediately at PEAK. + assert.equal(await h.engine.setSessionProtection(sessionID, "on"), "ON"); + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "released", + () => "blocked", + ); + await assertStillPending(held); + assert.equal((await h.engine.status({ ...selectedModel, sessionID })).sessionProtection, "ON"); + } finally { + await h.close(); + } +}); + +test("a committed request is never recalled by switching protection off or on", async () => { + const clock = new ManualClock("2026-10-12T03:59:59Z"); + const h = await harness("2026-10-12T03:59:59Z", { clock }); + const sessionID = "session-committed"; + let releases = 0; + try { + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then(() => { + releases += 1; + }); + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + // The allowed window arrives first: the dispatch decision is taken. + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(() => releases === 1); + // A late cancel reports the truth and a protection switch changes nothing. + assert.deepEqual(await h.engine.cancelHold({ sessionID }), { + cancelled: false, + state: "committed", + }); + await h.engine.setSessionProtection(sessionID, "off"); + await h.engine.setSessionProtection(sessionID, "on"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(releases, 1); + assert.equal(h.sessions.prompts.length, 0); + assert.equal(h.sessions.interrupts, 0); + await held; + } finally { + await h.close(); + } +}); + +test("an already cancelled/refused hold is not resurrected by session off", async () => { + const h = await harness(PEAK); + const sessionID = "session-cancelled"; + try { + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then(() => "returned"); + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + const cancelled = await h.engine.cancelHold({ sessionID, reason: "obsolete" }); + assert.equal(cancelled.cancelled, true); + assert.equal(cancelled.state, "refused"); + // Switching protection off after the cancellation must not release or + // resurrect the refused request: the refusal stays armed and is consumed at + // the transport boundary without any provider dispatch. + await h.engine.setSessionProtection(sessionID, "off"); + assert.equal(await held, "returned"); + const consumed = h.engine.consumeRefusal({ + sessionID, + ...selectedModel, + kind: "primary", + }); + assert.equal(consumed, true); + assert.equal(h.engine.consumeRefusal({ sessionID, ...selectedModel, kind: "primary" }), false); + assert.equal(h.sessions.prompts.length, 0); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + // The consumed refusal still keeps offpeak's own retry recovery away. + const outcome = await h.engine.noteRetryFailure({ + sessionID, + ...selectedModel, + errorType: "provider.transport", + willRetry: true, + }); + assert.equal(outcome, "pass"); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + } finally { + await h.close(); + } +}); + +test("session off allows and session on restores compaction, title, and generate requests alike", async () => { + const h = await harness(PEAK); + const sessionID = "session-native"; + try { + for (const kind of ["compaction", "title", "generate"]) { + const wait = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind, transport: "model" }) + .then( + () => "released", + () => "blocked", + ); + await assertStillPending(wait); + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 1); + await h.engine.setSessionProtection(sessionID, "off"); + assert.equal(await wait, "released"); + // The release removes any marker the hold had persisted. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + await h.engine.setSessionProtection(sessionID, "on"); + } + // Under protection ON the same compaction would persist a pending-compaction + // marker instead of running; that v0.1.9 behavior is asserted by the + // dedicated regression below. + } finally { + await h.close(); + } +}); + +test("UNKNOWN models and invalid configuration fail closed even with session protection off", async () => { + const h = await harness(PEAK); + const sessionID = "session-fail-closed"; + try { + await h.engine.setSessionProtection(sessionID, "off"); + await assert.rejects( + h.engine.guardModelRequest({ + sessionID, + providerID: "opencode-go", + modelID: "some-unlisted-model", + kind: "primary", + transport: "model", + }), + (error) => error instanceof PricingBlockedError && /not in the verified/.test(error.message), + ); + await writeFile(h.config.path, "{ broken json"); + await assert.rejects( + h.engine.guardModelRequest({ + sessionID, + ...selectedModel, + kind: "primary", + transport: "model", + }), + PricingBlockedError, + ); + } finally { + await h.close(); + } +}); + +test("multi-session isolation: A off, B and C remain protected, then A on again", async () => { + const h = await harness(PEAK); + try { + await h.engine.setSessionProtection("session-a", "off"); + const a = h.engine + .guardModelRequest({ + sessionID: "session-a", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then( + () => "released", + () => "blocked", + ); + assert.equal(await a, "released"); + const b = h.engine + .guardModelRequest({ + sessionID: "session-b", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then( + () => "released", + () => "blocked", + ); + const c = h.engine + .guardModelRequest({ + sessionID: "session-c", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then( + () => "released", + () => "blocked", + ); + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 2); + assert.equal( + (await h.engine.status({ ...selectedModel, sessionID: "session-b" })).sessionProtection, + "ON", + ); + assert.equal( + (await h.engine.status({ ...selectedModel, sessionID: "session-c" })).sessionProtection, + "ON", + ); + await h.engine.setSessionProtection("session-a", "on"); + await assertStillPending(b); + await assertStillPending(c); + assert.equal( + (await h.engine.status({ ...selectedModel, sessionID: "session-a" })).sessionProtection, + "ON", + ); + } finally { + await h.close(); + } +}); + +test("queue-owned sessions never inherit the interactive override", async () => { + const h = await harness("2026-10-12T00:30:00Z"); + const providerRequests = { count: 0 }; + h.sessions.behavior = async ({ sessionID }) => { + // This task starts off-peak; its provider attempt crosses into PEAK. + await h.engine.guardRequest({ sessionID, ...selectedModel, transport: "http" }); + providerRequests.count += 1; + h.clock.set(PEAK); + await h.engine.guardRequest({ sessionID, ...selectedModel, transport: "http" }); + providerRequests.count += 1; + }; + try { + const task = await h.engine.queue({ + prompt: "crosses into peak", + cwd: "/project", + model: selectedModel, + }); + await until(async () => { + const stored = (await h.engine.list()).find((item) => item.id === task.id); + return stored?.state === "WAITING_OFFPEAK" && stored.sessionID !== undefined; + }); + const stored = (await h.engine.list()).find((item) => item.id === task.id); + // The queue session carries an explicit user override; the task still must + // not inherit it, because background queue tasks keep their own semantics. + assert.equal(await h.engine.setSessionProtection(stored.sessionID, "off"), "OFF"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(providerRequests.count, 1); + assert.equal( + (await h.engine.list()).find((item) => item.id === task.id).state, + "WAITING_OFFPEAK", + ); + // The explicit task-scoped override still works exactly as before. + await h.engine.runNow(task.id); + await until(async () => providerRequests.count === 2); + await until( + async () => + (await h.engine.list()).find((item) => item.id === task.id)?.state === "COMPLETED", + ); + } finally { + await h.close(); + } +}); + +test("session off in queue and guard modes allows interactive PEAK requests; session on blocks them again", async () => { + for (const mode of ["queue", "guard"]) { + const h = await harness(PEAK, { options: { mode } }); + try { + assert.equal((await h.engine.status(selectedModel)).mode, mode); + await assert.rejects( + h.engine.guardModelRequest({ + sessionID: "session-mode", + ...selectedModel, + kind: "primary", + transport: "model", + }), + PricingBlockedError, + ); + await h.engine.setSessionProtection("session-mode", "off"); + await h.engine.guardModelRequest({ + sessionID: "session-mode", + ...selectedModel, + kind: "primary", + transport: "model", + }); + await h.engine.setSessionProtection("session-mode", "on"); + await assert.rejects( + h.engine.guardModelRequest({ + sessionID: "session-mode", + ...selectedModel, + kind: "primary", + transport: "model", + }), + PricingBlockedError, + ); + } finally { + await h.close(); + } + } +}); + +test("global OFF precedence: the explicit session override still authorizes that session only", async () => { + const h = await harness(PEAK); + try { + await h.engine.setEnabled(false); + await h.engine.setSessionProtection("session-allowed", "off"); + // The explicit session grant is newer and more specific than the global + // pause: that one session may run verified PEAK inference. + await h.engine.guardModelRequest({ + sessionID: "session-allowed", + ...selectedModel, + kind: "primary", + transport: "model", + }); + // Every other session keeps the hard barrier while the plugin is OFF. + await assert.rejects( + h.engine.guardModelRequest({ + sessionID: "session-protected", + ...selectedModel, + kind: "primary", + transport: "model", + }), + PricingBlockedError, + ); + // A task still cannot start while globally OFF. + const task = await h.engine.queue({ + prompt: "do not start", + cwd: "/project", + model: selectedModel, + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(h.sessions.prompts.length, 0); + assert.equal((await h.engine.list()).find((item) => item.id === task.id).state, "QUEUED"); + // run-now keeps requiring an enabled plugin. + await assert.rejects(h.engine.runNow(task.id), /Turn \/offpeak on/); + } finally { + await h.close(); + } +}); + +test("session protection persists across a plugin reload and a server restart", async () => { + const h = await harness(PEAK); + const sessionID = "session-persisted"; + try { + await h.engine.setSessionProtection(sessionID, "off"); + const persisted = h.storage.values.get(SESSION_OVERRIDES_STORAGE_KEY); + assert.deepEqual(Object.keys(persisted.sessions), [sessionID]); + assert.equal(persisted.sessions[sessionID].protection, "off"); + assert.equal(typeof persisted.sessions[sessionID].updatedAt, "string"); + await h.engine.dispose(); + const reloaded = await restart(h); + assert.equal(reloaded.sessionProtection(sessionID), "OFF"); + await reloaded.guardModelRequest({ + sessionID, + ...selectedModel, + kind: "primary", + transport: "model", + }); + await reloaded.dispose(); + const restarted = await restart(h); + assert.equal(restarted.sessionProtection(sessionID), "OFF"); + assert.equal( + (await restarted.status({ ...selectedModel, sessionID })).sessionProtection, + "OFF", + ); + await restarted.setSessionProtection(sessionID, "on"); + assert.deepEqual(h.storage.values.get(SESSION_OVERRIDES_STORAGE_KEY), { + schemaVersion: 1, + sessions: {}, + }); + await restarted.dispose(); + } finally { + await h.close(); + } +}); + +test("session-deletion events and lazy access reap overrides for missing sessions only", async () => { + const h = await harness(PEAK); + try { + await h.engine.setSessionProtection("session-existing", "off"); + await h.engine.setSessionProtection("session-deleted", "off"); + // The supported session.deleted event removes only that session's override. + await h.engine.forgetSession("session-deleted"); + assert.equal(h.engine.sessionProtection("session-deleted"), "ON"); + assert.equal(h.engine.sessionProtection("session-existing"), "OFF"); + // Lazy status access prunes a session that no longer exists. + h.sessions.missing.add("session-gone"); + await h.engine.setSessionProtection("session-gone", "off"); + const status = await h.engine.status({ ...selectedModel, sessionID: "session-gone" }); + assert.equal(status.sessionProtection, "ON"); + assert.equal(h.engine.sessionProtection("session-gone"), "ON"); + // A session that still exists is never reaped. + assert.equal( + (await h.engine.status({ ...selectedModel, sessionID: "session-existing" })) + .sessionProtection, + "OFF", + ); + // Start-up pruning also drops overrides for sessions that are already missing. + h.sessions.missing.add("session-existing"); + const restarted = await restart(h); + assert.equal(restarted.sessionProtection("session-existing"), "ON"); + assert.deepEqual(h.storage.values.get(SESSION_OVERRIDES_STORAGE_KEY), { + schemaVersion: 1, + sessions: {}, + }); + await restarted.dispose(); + } finally { + await h.close(); + } +}); + +test("a lost deferred hold proceeds immediately when the session is switched off", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "session-recovery"; + try { + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "released", + () => "blocked", + ); + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + // The plugin reloads while the request waits: the hold fails closed and the + // deferral stays for recovery. + await h.engine.dispose(); + assert.equal(await held, "blocked"); + const reloaded = await restart(h); + h.sessions.outcomes.set(sessionID, "failed"); + // Protection ON: the recovery waits for the boundary. + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.prompts.length, 0); + assert.equal((await reloaded.status(selectedModel)).recoveryWaiting, 1); + // Switching protection off releases the pending recovery on the spot, once. + await reloaded.setSessionProtection(sessionID, "off"); + await until(() => h.sessions.prompts.length === 1); + assert.equal(h.sessions.prompts[0].sessionID, sessionID); + assert.match(h.sessions.prompts[0].text, /Continue the task in this session/); + assert.equal((await reloaded.status(selectedModel)).recoveryWaiting, 0); + // The boundary must not deliver a duplicate continuation. + await clock.advanceTo("2026-10-12T04:00:00Z"); + await clock.advanceTo("2026-10-13T00:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.prompts.length, 1); + await reloaded.dispose(); + } finally { + await h.close(); + } +}); + +test("retry-failure recovery proceeds immediately under session off without a duplicate retry continuation", async () => { + const h = await harness(PEAK); + const sessionID = "session-retry-recovery"; + try { + h.sessions.outcomes.set(sessionID, "failed"); + const outcome = await h.engine.noteRetryFailure({ + sessionID, + ...selectedModel, + errorType: "provider.transport", + willRetry: true, + }); + assert.equal(outcome, "hold"); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 1); + await h.engine.setSessionProtection(sessionID, "off"); + await until(() => h.sessions.prompts.length === 1); + assert.equal(h.sessions.prompts[0].sessionID, sessionID); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + // A retryable failure in an overridden session keeps OpenCode's own retry + // behavior instead of registering a recovery. + const second = await h.engine.noteRetryFailure({ + sessionID, + ...selectedModel, + errorType: "provider.transport", + willRetry: true, + }); + assert.equal(second, "pass"); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + } finally { + await h.close(); + } +}); + +test("pending-compaction and pending-title recoveries honor the override without class clobbering", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const storage = new MemoryStorage(); + const sessions = new FakeSessions(); + // Seed the exact persisted recovery classes v0.1.9 writes. + await new RecoveryRepository(storage).save({ + schemaVersion: 1, + entries: [ + { + sessionID: "session-compaction", + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + directory: "test-location", + heldAt: "2026-10-12T01:31:00.000Z", + origin: "pending-compaction", + }, + { + sessionID: "session-title", + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + directory: "test-location", + heldAt: "2026-10-12T01:31:00.000Z", + origin: "pending-title", + }, + ], + }); + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-session-recovery-")); + const config = new ConfigRepository({ configPath: path.join(root, "offpeak.json") }); + sessions.outcomes.set("session-compaction", "failed"); + sessions.outcomes.set("session-title", "failed"); + const order = []; + sessions.compact = async (sessionID) => { + order.push(`compact:${sessionID}`); + sessions.compactions.push(sessionID); + }; + const engine = new OffpeakEngine({ + repository: new QueueRepository(storage), + recoveries: new RecoveryRepository(storage), + sessionOverrides: new SessionOverridesRepository(storage), + location: "test-location", + config, + sessions, + clock, + }); + await engine.start(); + try { + // Protection is ON: the pending compaction is admitted by the v0.1.9 + // auto-mode rule, but the title marker only verifies (nothing is sent). + await until(() => sessions.compactions.length === 1); + assert.equal(await engine.setSessionProtection("session-title", "off"), "OFF"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(sessions.prompts.length, 0); + assert.equal(sessions.compactions.length, 1); + // The override never clobbers another session's recovery. + const document = await new RecoveryRepository(storage).load(); + assert.deepEqual( + document.entries.map((entry) => `${entry.sessionID}:${entry.origin}`), + ["session-title:pending-title"], + ); + // Once OpenCode names the session, the marker clears without a request. + sessions.snapshots.set("session-title", { title: "Named by OpenCode" }); + await engine.noteExecutionSettled("session-title"); + await until(async () => (await new RecoveryRepository(storage).load()).entries.length === 0); + } finally { + await engine.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("compaction and primary recoveries for one overridden session flush in compact-first order, exactly once", async () => { + const clock = new ManualClock("2026-10-12T02:30:00Z"); + const storage = new MemoryStorage(); + const sessions = new FakeSessions(); + await new RecoveryRepository(storage).save({ + schemaVersion: 1, + entries: [ + { + sessionID: "session-both", + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + directory: "test-location", + heldAt: "2026-10-12T01:31:00.000Z", + origin: "pending-compaction", + }, + { + sessionID: "session-both", + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + directory: "test-location", + heldAt: "2026-10-12T01:31:00.000Z", + origin: "deferred", + }, + ], + }); + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-session-order-")); + const config = new ConfigRepository({ configPath: path.join(root, "offpeak.json") }); + const order = []; + sessions.outcomes.set("session-both", "failed"); + sessions.compact = async (sessionID) => { + order.push("compact"); + sessions.compactions.push(sessionID); + }; + const engine = new OffpeakEngine({ + repository: new QueueRepository(storage), + recoveries: new RecoveryRepository(storage), + sessionOverrides: new SessionOverridesRepository(storage), + location: "test-location", + config, + sessions, + clock, + }); + await engine.start(); + try { + // Protection ON: the compaction is admitted, the primary continuation is + // forced with it (v0.1.9 compact-first), then no duplicate follows. + await until(() => sessions.prompts.length === 1); + assert.deepEqual(order, ["compact"]); + assert.equal(sessions.prompts[0].sessionID, "session-both"); + // Turning protection off afterwards cannot replay anything (the entries are + // already consumed). + await engine.setSessionProtection("session-both", "off"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(sessions.prompts.length, 1); + assert.equal(sessions.compactions.length, 1); + } finally { + await engine.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("compaction regression: ON waits with a pending-compaction marker, OFF runs immediately without one", async () => { + // A: protection ON during PEAK keeps the v0.1.9 pending-compaction behavior. + const onClock = new ManualClock("2026-10-12T01:30:00Z"); + const on = await harness("2026-10-12T01:30:00Z", { clock: onClock }); + try { + const held = on.engine + .guardModelRequest({ + sessionID: "session-compact-on", + ...selectedModel, + kind: "compaction", + transport: "model", + }) + .then( + () => "released", + () => "blocked", + ); + await until( + async () => (await on.engine.status(selectedModel)).compactionRecoveryWaiting === 1, + ); + await assertStillPending(held); + await onClock.advanceTo("2026-10-12T04:00:00Z"); + assert.equal(await held, "released"); + assert.equal((await on.engine.status(selectedModel)).recoveryWaiting, 0); + assert.equal(on.sessions.prompts.length, 0); + } finally { + await on.close(); + } + + // B: protection OFF during PEAK runs the compaction immediately and leaves no + // marker behind; the next prompt works normally. + const off = await harness(PEAK); + try { + await off.engine.setSessionProtection("session-compact-off", "off"); + await off.engine.guardModelRequest({ + sessionID: "session-compact-off", + ...selectedModel, + kind: "compaction", + transport: "model", + }); + const status = await off.engine.status(selectedModel); + assert.equal(status.compactionRecoveryWaiting, 0); + assert.equal(status.recoveryWaiting, 0); + assert.equal(status.interactiveWaiting, 0); + await off.engine.guardRequest({ + sessionID: "session-compact-off", + ...selectedModel, + transport: "http", + }); + assert.equal(off.sessions.prompts.length, 0); + assert.equal(off.sessions.compactions.length, 0); + } finally { + await off.close(); + } +}); + +test("switching protection off after a held auxiliary request notifies no hold/resume pair", async () => { + const h = await harness(PEAK); + const sessionID = "session-aux"; + try { + const held = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .then(() => "released"); + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); + await h.engine.setSessionProtection(sessionID, "off"); + assert.equal(await held, "released"); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(h.notifications, []); + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 0); + } finally { + await h.close(); + } +}); From 6f47c2474b863b8441e1b622575694b6cd09425c Mon Sep 17 00:00:00 2001 From: Lutz Kind <132444134+lutzkind@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:45:03 +0000 Subject: [PATCH 2/2] docs: release v0.1.10 (session-scoped off-peak protection) and reconcile current-state wording - README: install #v0.1.10, validation includes 2.0.30, session on/off section (current session only, default ON, persistent, no timer, UNKNOWN still blocked, queue tasks unaffected, status output, compaction behavior, explicit global precedence) - CHANGELOG 0.1.10, RELEASING current release, docs/configuration and docs/troubleshooting updated; ARCHITECTURE session-protection section; DECISIONS entry for the per-session authorization - HANDOFF lower current-state sections: package/install now v0.1.10, verified CLI includes 2.0.30, implemented-behavior list includes session controls --- ARCHITECTURE.md | 14 ++++++++++++- CHANGELOG.md | 12 +++++++++++ DECISIONS.md | 8 ++++++++ HANDOFF.md | 12 +++++------ README.md | 45 +++++++++++++++++++++++++++++++++-------- RELEASING.md | 6 +++--- docs/configuration.md | 2 ++ docs/troubleshooting.md | 6 +++++- package.json | 2 +- 9 files changed, 87 insertions(+), 20 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6153a0c..be4bd7e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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 @@ -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 ` 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. @@ -126,6 +136,8 @@ Recovery delivery is guarded against live work so it can never duplicate it. `se `/offpeak run-now ` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fcfd0..10581e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/DECISIONS.md b/DECISIONS.md index 92a63ee..8211456 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/HANDOFF.md b/HANDOFF.md index dd01023..7b326ad 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -349,23 +349,23 @@ The implementation commit at the v0.1.0 release was `2782893c2e09eff6116f77c62b6 - Canonical checkout: `/root/opencode-offpeak` - Default branch: `main` -- Package: `opencode-offpeak@0.1.6`, MIT +- Package: `opencode-offpeak@0.1.10`, MIT - Verified CLI: OpenCode v2.0.26, v2.0.27 and v2.0.30 - Declared supported range: `>=2.0.26 <2.1.0`, enforced at startup and declared in `engines.opencode` -- GitHub install after release: `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6` +- GitHub install after release: `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10` - Package exports OpenCode v2 server and TUI plugins. The TUI registers `/offpeak` and uses OpenCode plugin RPC to control the server-side queue. ## Architecture One package uses OpenCode v2 request hooks, session APIs, plugin storage, RPC, and background server. Queue metadata is persisted in OpenCode plugin storage. The persistent toggle and policy configuration live in `$OPENCODE_CONFIG_DIR/offpeak.json`, or `$XDG_CONFIG_HOME/opencode/offpeak.json` by default. Task records keep explicit OpenCode session IDs. There is no database, second agent runtime, daemon, remote queue, or telemetry. -The request guard checks HTTP and experimental WebSocket model requests before transport. It fails closed for invalid configuration, invalid pricing data, and unknown models under a configured provider. `OFF` pauses queue automation and keeps the hard guard active; only the explicit task-scoped 15-minute `/offpeak run-now ` action can authorize peak inference. +The request guard checks HTTP and experimental WebSocket model requests before transport. It fails closed for invalid configuration, invalid pricing data, and unknown models under a configured provider. `OFF` pauses queue automation and keeps the hard guard active; only the explicit task-scoped 15-minute `/offpeak run-now ` action can authorize peak inference for a task, and only the explicit session-scoped `/offpeak session off` can authorize verified peak inference for one interactive session. -Two built-in policies ship (v0.1.6): `deepseek-opencode-go` (provider `opencode-go`) and `deepseek-api` (provider `deepseek`, the direct DeepSeek API), sharing one verified UTC schedule — weekdays 01:00–04:00 and 06:00–10:00 UTC are peak; weekends, the remaining hours, and Chinese statutory public holidays are off-peak — and one shared Chinese holiday calendar. Vendor-documented flat-priced models evaluate `FLAT` (never held); a model with no verified entry fails closed. OpenCode Zen (provider `opencode`) has no time-based pricing at all and therefore no policy: its requests are never delayed. Each policy carries its own source URL and `verifiedAt` date; provider terms can change, so re-verify the sources before updating a policy (see `docs/pricing-policies.md`). +Two built-in policies ship: `deepseek-opencode-go` (provider `opencode-go`) and `deepseek-api` (provider `deepseek`, the direct DeepSeek API), sharing one verified UTC schedule — weekdays 01:00–04:00 and 06:00–10:00 UTC are peak; weekends, the remaining hours, and Chinese statutory public holidays are off-peak — and one shared Chinese holiday calendar. Vendor-documented flat-priced models evaluate `FLAT` (never held); a model with no verified entry fails closed. OpenCode Zen (provider `opencode`) has no time-based pricing at all and therefore no policy: its requests are never delayed. Each policy carries its own source URL and `verifiedAt` date; provider terms can change, so re-verify the sources before updating a policy (see `docs/pricing-policies.md`). ## Implemented behavior -- Persistent `/offpeak on`, `/offpeak off`, `/offpeak status`, `/offpeak pricing`, `/offpeak list`, `/offpeak queue`, `/offpeak cancel`, `/offpeak retry`, `/offpeak resume`, and `/offpeak run-now` controls. +- Persistent `/offpeak on`, `/offpeak off`, `/offpeak session on`, `/offpeak session off`, `/offpeak status`, `/offpeak pricing`, `/offpeak list`, `/offpeak queue`, `/offpeak cancel`, `/offpeak retry`, `/offpeak resume`, and `/offpeak run-now` controls. Session protection is per session, defaults to ON, persists until changed, and never affects another session or a queue task. - Sequential queue with FIFO order, task-specific model/policy/working directory, explicit session binding, cancellation, retry, and visible failure state. - `auto`, `queue`, and `guard` modes; automatic same-session pause/resume in `auto` mode. - Request-boundary block for peak pricing, including tasks that cross a pricing boundary. Local tools already in flight are allowed to settle. @@ -407,7 +407,7 @@ The 2026-10-12 repair used OpenCode's supported `opencode reload` on the shared ## User action required -None. The v0.1.6 GitHub release is the complete public distribution path and installs through OpenCode's supported `opencode plugin add` Git specification. The server-side plugin is already reloaded to v0.1.6; a TUI process started before the reload (for example PID 4053968) keeps its older TUI entrypoint until it is relaunched, so relaunch a TUI to see the new `/offpeak status` wording and the hold/resume toasts. Plugin behavior (holding, recovery, notifications on the event stream) is server-side and unaffected by an older TUI process. +None. The v0.1.10 GitHub release is the complete public distribution path and installs through OpenCode's supported `opencode plugin add` Git specification. The server-side plugin is already reloaded to v0.1.10; a TUI process started before the reload keeps its older TUI entrypoint until it is relaunched, so relaunch a TUI to see the `/offpeak session on|off` command and the `Session protection` status line. Plugin behavior (holding, recovery, session overrides) is server-side and unaffected by an older TUI process. Running `/offpeak session on|off` requires a focused session; on the home screen the command refuses instead of guessing. ## Next normal maintenance diff --git a/README.md b/README.md index b7d0fef..8298fe5 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing windows, and resume the **same OpenCode session** when off-peak pricing returns. -`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. +`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 and 2.0.27 (the current official v2 release). 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.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. ## Install ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10 ``` -This installs the released `v0.1.6` 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.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. 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. @@ -33,6 +33,8 @@ Select the provider and model first. Queueing captures the current model and wor ```text /offpeak on /offpeak off +/offpeak session on +/offpeak session off /offpeak pricing /offpeak retry /offpeak resume @@ -41,7 +43,9 @@ Select the provider and model first. Queueing captures the current model and wor - `/offpeak on` persists the enabled setting and resumes queue processing. - `/offpeak off` persists the disabled setting and pauses automatic starts/resumes. -- `/offpeak status` shows the toggle, mode, model policy, price state, request decision, next transition, queue counts, held interactive requests, sessions waiting for off-peak recovery, active session, and any override. +- `/offpeak session on` restores normal off-peak protection for the current session (the default). +- `/offpeak session off` disables off-peak protection for the current session only: that session's verified peak-priced requests run immediately, and any request it already has waiting for off-peak is released. It is an explicit, session-scoped peak authorization — not a global toggle and not a bypass of `UNKNOWN` or invalid-configuration protection. +- `/offpeak status` shows the toggle, mode, model policy, price state, request decision, next transition, queue counts, held interactive requests, sessions waiting for off-peak recovery, the current session's protection state, and any override. - `/offpeak list` shows task IDs, states, stored session IDs, and the last failure. - `/offpeak cancel ` cancels the task and preserves its session record. - `/offpeak remove ` removes a terminal (`COMPLETED`, `FAILED`, or `CANCELLED`) task from the queue. Active tasks must be cancelled first; the OpenCode session is preserved. @@ -49,7 +53,7 @@ Select the provider and model first. Queueing captures the current model and wor - `/offpeak resume ` requests a waiting task's continuation in `queue` mode. - `/offpeak run-now ` grants a visible, task-scoped 15-minute peak-price override. It requires the plugin to be ON and can consume OpenCode Go allowance at peak rates. -**Disabling the plugin pauses its automation. It does not mean “run everything regardless of price.”** Queue records and session IDs remain stored. The hard peak guard remains active while the plugin is OFF; OFF never creates an override and never releases a request that is already held at the boundary. A request held in `auto` mode resumes only after the plugin is ON again and pricing allows. +**Disabling the plugin pauses its automation. It does not mean “run everything regardless of price.”** Queue records and session IDs remain stored. The hard peak guard remains active while the plugin is OFF; OFF never creates an override and never releases a request that is already held at the boundary. A request held in `auto` mode resumes only after the plugin is ON again and pricing allows. The single exception is an explicit per-session grant made with `/offpeak session off` (see below), which applies to that one interactive session. ## What happens at a pricing boundary @@ -80,6 +84,29 @@ 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. +### 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: + +```text +/offpeak session off → Session off-peak protection disabled. + This session may use verified peak-priced inference. +/offpeak session on → Session off-peak protection enabled. + Peak-priced requests will wait for off-peak. +``` + +- **Current session only.** The command applies to the interactive session you are in. The session is resolved from OpenCode's supported TUI command context (the focused route, then the active session tab); you never copy a session ID. Run the command from an open session — on the home screen, with no session focused, it refuses with a clear message. `/offpeak session off` never affects another session, never affects background queue tasks, and never disables the plugin globally. +- **Default is ON** for every session. Only explicit overrides are stored; there is no way to accidentally inherit one. +- **Persistent until changed.** The override is stored in OpenCode plugin storage and survives TUI restarts, plugin reloads, and full server restarts. There is no timer, no expiry, and no duration syntax. `/offpeak session on` ends it immediately. When the session is deleted (OpenCode's supported `session.deleted` event), the override is reaped; stale entries for sessions that no longer exist are also cleaned lazily when state is read. +- **Verified PEAK only.** With protection off, a request that evaluates `PEAK` under a verified policy is allowed immediately — primary, compaction, title, and generate requests alike, because they all belong to the session you authorized. `UNKNOWN` models and invalid configuration still fail closed. `OFF_PEAK`, `FLAT`, and `UNTRACKED` behave exactly as before. +- **Waiting work wakes immediately.** If the session already has a request held for off-peak, `/offpeak session off` releases it through the existing engine signal: the same request continues in the same session with no `continue` prompt, no re-sent message, no duplicate provider call, and no polling loop. The TUI shows "Session off-peak protection is OFF. Releasing the held request in this session now." Pending recoveries for that session (a deferred primary, a retry-failure continuation, a pending title, or a pending compaction) also proceed immediately, still in their native order. +- **Already-settled work is never recalled.** A committed dispatch (the allowed window already arrived) or a cancelled/refused hold is unaffected: switching protection off cannot resurrect it, and switching it on never interrupts a running provider request. The next request that crosses the guard during peak waits normally again. +- **Cancellation semantics are preserved.** A hold cancelled through `cancelHold` stays refused regardless of later protection changes. +- **Queue tasks stay separate.** A background queue task never inherits a session override, even when it runs in the same OpenCode session; `/offpeak run-now ` remains the task-scoped authorization. +- **Global precedence.** `/offpeak session off` is an explicit, session-scoped authorization, newer and more specific than the global automation state: in `auto`, `queue`, and `guard` modes, and even while the plugin is globally OFF, it lets that one session dispatch verified peak-priced work. Everything else stays untouched by the global state: OFF still pauses every queue start and auto-resume, still blocks every session that was not explicitly switched off, still blocks every task, and still never releases a held request by itself. +- **Status output.** `/offpeak status` reports the current session's protection, and while peak pricing is active with protection off it makes the effective behavior explicit — `Pricing: PEAK`, `Session protection: OFF`, `Requests: ALLOWED (session override)`. Pricing is never reported as off-peak when it is peak. +- **Compaction.** With protection off, a manual or automatic compaction during peak runs immediately and leaves no pending-compaction marker; with protection on, the compact-first behavior documented above is unchanged. + ### Timeout and retry recovery A request can start during off-peak, be accepted by the provider, cross into a peak window, and then fail with a retryable provider or network error such as `Timed out waiting for response headers`. That failure is real (the request was already sent), so the plugin's job is to make sure nothing further reaches the provider and the work is not lost. OpenCode normally retries such failures, and a retry attempt is itself a new paid request: during peak pricing it must not reach the provider, and the work must not be lost when OpenCode's retry budget runs out. @@ -108,7 +135,7 @@ Set `mode` in `~/.config/opencode/offpeak.json`: - `queue`: start queued tasks off-peak; a task paused at peak waits for `/offpeak resume ` or `/offpeak run-now `; interactive peak requests are blocked immediately by the transport guard. - `guard`: enforce request pricing for interactive OpenCode sessions, but do not automatically start queued tasks or defer interactive requests for a later window. -All queue automation respects the persistent `enabled` setting. The guard itself does not turn OFF into a price bypass. +All queue automation respects the persistent `enabled` setting. The guard itself does not turn OFF into a price bypass. A session you explicitly switched with `/offpeak session off` is the documented exception: that one interactive session may dispatch verified peak-priced work in any mode, and every other session and every task keeps the behavior above. ## Pricing policy @@ -140,7 +167,7 @@ The Go peak model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4 ### Held-request feedback -When an interactive primary request is held for peak pricing, the TUI shows a non-chat toast — "Peak pricing active — request held until 08:00 Asia/Bangkok. It will resume automatically in this session." — and another when off-peak returns. The deadline is rendered in the configured `displayTimeZone`. These notifications travel on OpenCode's own event stream (`rpc.opencode-offpeak.hold` / `rpc.opencode-offpeak.resume`); they never enter the session or the model context, they are not repeated, a user cancel produces no false resumption, and auxiliary `title`/`compaction`/`generate` requests stay silent. Headless clients such as `opencode run` have no subscriber and remain silent. +When an interactive primary request is held for peak pricing, the TUI shows a non-chat toast — "Peak pricing active — request held until 08:00 Asia/Bangkok. It will resume automatically in this session." — and another when off-peak returns ("Off-peak pricing started. Resuming the held request in this session."). If the hold is released by `/offpeak session off` while pricing is still peak, the release toast says so explicitly ("Session off-peak protection is OFF. Releasing the held request in this session now."). The deadline is rendered in the configured `displayTimeZone`. These notifications travel on OpenCode's own event stream (`rpc.opencode-offpeak.hold` / `rpc.opencode-offpeak.resume`); they never enter the session or the model context, they are not repeated, a user cancel produces no false resumption, and auxiliary `title`/`compaction`/`generate` requests stay silent. Headless clients such as `opencode run` have no subscriber and remain silent. Pricing details can change. This release verified the built-in schedules, the model lists, the Zen flat-pricing fact, and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [OpenCode Zen pricing page](https://opencode.ai/docs/zen/), 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. @@ -177,6 +204,8 @@ See [configuration](docs/configuration.md) and [pricing policies](docs/pricing-p - A retryable provider failure during peak never retries into the provider. OpenCode's agent-loop request is retried with jittered exponential backoff; header-timeout failures are bounded by OpenCode to three retries (attempts 2–4, denied at attempt 5). The plugin observes every retry decision through the supported `retry` hook. - The continuation prompt asks the model to continue from the current project state; the plugin does not replay the exact failed provider request. A transport failure can be ambiguous (the provider may have received the request before the error), which is why the continuation is a state-aware new turn instead of a byte-for-byte replay. - A request deferred at the pre-model hook lives in the running OpenCode server process; a plugin/server shutdown fails it closed as blocked rather than sending it under an uncertain pricing state. A retryable provider failure during peak instead records minimal recovery metadata in OpenCode plugin storage, so the same session continues at the next off-peak window even across a reload. +- A session whose protection was explicitly switched off (`/offpeak session off`) keeps that override across reloads and restarts until it is switched back on or the session is deleted; while it is off, peak-priced dispatches in that session are real peak-rate inference and are billed accordingly. +- `/offpeak session on|off` needs a focused session; on the home screen it refuses rather than guessing which session was meant. - If OpenCode's own location-activity sweep interrupts a deferred session (no durable session events for 60 minutes), the plugin records the same persisted recovery and the session continues at the next allowed window; user interrupts are never converted into recoveries. - A session that never settles after a retryable failure (no outcome is recorded) is not continued automatically; its recovery record is dropped without sending anything rather than injecting a message into an unclear session. - The plugin blocks at the provider request boundary. It does not roll back a request already accepted by a provider or interrupt an already-running local tool operation. diff --git a/RELEASING.md b/RELEASING.md index 8fc4567..9eff0ec 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,13 @@ ## Current public distribution -`v0.1.6` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: +`v0.1.10` 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.6 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10 ``` -`v0.1.0` through `v0.1.5` remain available at their own tags, but `v0.1.6` is the canonical release: it adds first-class direct DeepSeek API support (a second built-in policy, `deepseek-api`, covering the verified `deepseek` provider model IDs with the shared peak windows and shared Chinese holiday calendar, with no `deepseek-*` wildcard), documents OpenCode Zen as a verified flat-priced provider that is never delayed (no policy by design), and gives held primary interactive requests immediate non-chat TUI feedback through plugin RPC events on OpenCode's own event stream (hold and resume toasts with the configured display-zone deadline; auxiliary requests stay silent; headless runs stay silent; reloads and restarts cannot duplicate a notification). This sits on top of 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.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. ## Optional npm distribution diff --git a/docs/configuration.md b/docs/configuration.md index ebe59dd..ac007f5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,6 +23,8 @@ If `policies` is omitted, the built-in DeepSeek/OpenCode Go policy is used. If y - `displayTimeZone`: valid IANA timezone for status output; defaults to the system timezone. - `policies`: complete list of pricing policy definitions. See [pricing policies](pricing-policies.md). +`/offpeak session on|off` does not write this file. Session-scoped off-peak protection is stored as an explicit per-session override in OpenCode plugin storage (key `opencode-offpeak/session-overrides-v1`); the default for every session is ON, and absence of an entry means protection is on. The override is deliberately not part of `offpeak.json` because it belongs to one interactive session, not to the installation: it survives reloads and restarts with the session, is removed when the session is deleted, and never changes global settings. While a session's protection is off it may dispatch verified peak-priced work at peak rates (see the README section); `UNKNOWN` models and invalid configuration still fail closed for it. + The policy timezone is authoritative for pricing. Changing `displayTimeZone` does not move a pricing boundary. The running server watches the config file and reloads settings after atomic updates. ## Invalid configuration diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bd64b6e..050f9b5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,7 @@ ## `/offpeak` is not recognized -Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. +Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. ## `Keymap.Provider is missing` in the TUI @@ -18,6 +18,10 @@ Check status for `PEAK`, `UNKNOWN`, or `UNTRACKED`. `UNKNOWN` for a configured p If you intentionally accept peak pricing for a queued task, turn the plugin ON and use `/offpeak run-now `. The override expires after 15 minutes. +## One session should run through peak + +Use `/offpeak session off` in that session. It disables off-peak protection for the current interactive session only: its verified peak-priced requests run immediately (including compaction, title, and generate work), and a request it already has waiting for off-peak wakes on the spot — no `continue` message and no re-sent prompt. Every other session and every queue task stays protected. `/offpeak session on` restores the default. There is no timer: the override persists across reloads and restarts until you change it or the session is deleted. Run the command from an open session; on the home screen it refuses because no session is focused. `UNKNOWN` models and an invalid configuration still fail closed even for an overridden session, and `OFF_PEAK`/`FLAT`/`UNTRACKED` behavior is unchanged. `/offpeak status` shows `Session protection: ON|OFF` and, while peak is active with protection off, `Requests: ALLOWED (session override)`. + ## Interactive message waits at peak In `auto` mode, a supported interactive request sent during a peak window is deferred at OpenCode's pre-model hook — before any provider attempt (and its response-header timeout) starts — instead of being discarded or timing out, and the same session proceeds automatically when off-peak returns; no second message is needed. `/offpeak status` shows it under `Interactive` while it waits. In `queue` or `guard` mode, and while the plugin is OFF, interactive peak requests are blocked immediately by the transport guard. If a deferred request fails closed because the plugin or server shut down, resend it after the server is back. diff --git a/package.json b/package.json index 82e857c..1a7bec1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-offpeak", - "version": "0.1.9", + "version": "0.1.10", "description": "Queue OpenCode agent tasks and guard model requests against expensive pricing windows.", "author": "Lutz Kind", "license": "MIT",