@vikrant82/opencode-cache-keepalive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +4 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +582 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/lib/auth.d.ts +11 -0
  6. package/dist/lib/auth.d.ts.map +1 -0
  7. package/dist/lib/config.d.ts +31 -0
  8. package/dist/lib/config.d.ts.map +1 -0
  9. package/dist/lib/control.d.ts +8 -0
  10. package/dist/lib/control.d.ts.map +1 -0
  11. package/dist/lib/keepalive.d.ts +42 -0
  12. package/dist/lib/keepalive.d.ts.map +1 -0
  13. package/dist/lib/logger.d.ts +9 -0
  14. package/dist/lib/logger.d.ts.map +1 -0
  15. package/dist/lib/model.d.ts +8 -0
  16. package/dist/lib/model.d.ts.map +1 -0
  17. package/dist/lib/paths.d.ts +13 -0
  18. package/dist/lib/paths.d.ts.map +1 -0
  19. package/dist/lib/state.d.ts +74 -0
  20. package/dist/lib/state.d.ts.map +1 -0
  21. package/dist/lib/system.d.ts +11 -0
  22. package/dist/lib/system.d.ts.map +1 -0
  23. package/dist/lib/tui/commands.d.ts +3 -0
  24. package/dist/lib/tui/commands.d.ts.map +1 -0
  25. package/dist/lib/tui/footer.d.ts +14 -0
  26. package/dist/lib/tui/footer.d.ts.map +1 -0
  27. package/dist/lib/tui/format.d.ts +3 -0
  28. package/dist/lib/tui/format.d.ts.map +1 -0
  29. package/dist/tui.d.ts +7 -0
  30. package/dist/tui.d.ts.map +1 -0
  31. package/lib/auth.ts +33 -0
  32. package/lib/config.ts +101 -0
  33. package/lib/control.ts +37 -0
  34. package/lib/keepalive.ts +345 -0
  35. package/lib/logger.ts +21 -0
  36. package/lib/model.ts +19 -0
  37. package/lib/paths.ts +31 -0
  38. package/lib/state.ts +157 -0
  39. package/lib/system.ts +19 -0
  40. package/lib/tui/commands.ts +54 -0
  41. package/lib/tui/footer.tsx +156 -0
  42. package/lib/tui/format.ts +13 -0
  43. package/package.json +71 -0
  44. package/tui.tsx +25 -0
@@ -0,0 +1,345 @@
1
+ import type { KeepaliveConfig } from "./config"
2
+ import { readControl } from "./control"
3
+ import { isEligibleModel } from "./model"
4
+ import type { KeepaliveStore, SessionKeepalive } from "./state"
5
+ import type { Logger } from "./logger"
6
+
7
+ const TICK_MS = 15_000
8
+ const JITTER_MS = 15_000
9
+ const CONTROL_POLL_MS = 1_000
10
+ const STATUS_POLL_MS = 2_000
11
+
12
+ /**
13
+ * The keepalive engine.
14
+ *
15
+ * Lifecycle per session:
16
+ * 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.
17
+ * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
18
+ * 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses
19
+ * the session's model/auth), reads `usage` to confirm a cache hit, then optionally
20
+ * reverts the `~`/`~` turn so it does not pollute the real conversation.
21
+ * 4. A new real user turn or the window closing stops warming.
22
+ */
23
+ export class KeepaliveEngine {
24
+ private timer: ReturnType<typeof setInterval> | undefined
25
+ private controlTimer: ReturnType<typeof setInterval> | undefined
26
+ private statusTimer: ReturnType<typeof setInterval> | undefined
27
+ private controlUpdatedAt = 0
28
+ private reconcilingStatus = false
29
+ private enabled: boolean
30
+
31
+ constructor(
32
+ private readonly client: any,
33
+ private readonly config: KeepaliveConfig,
34
+ private readonly store: KeepaliveStore,
35
+ private readonly logger: Logger,
36
+ ) {
37
+ const control = readControl()
38
+ this.enabled = control?.enabled ?? config.enabled
39
+ this.controlUpdatedAt = control?.updatedAt ?? 0
40
+ this.store.setEnabled(this.enabled)
41
+ }
42
+
43
+ start(): void {
44
+ if (this.timer) return
45
+ this.timer = setInterval(() => void this.tick(), TICK_MS)
46
+ this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS)
47
+ this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS)
48
+ this.store.persist()
49
+ this.logger.info(
50
+ `started (${this.enabled ? "enabled" : "disabled"}, ` +
51
+ `interval=${Math.round(this.config.intervalMs / 1000)}s, ` +
52
+ `window=${Math.round(this.config.windowMs / 60_000)}m)`,
53
+ )
54
+ }
55
+
56
+ stop(): void {
57
+ if (!this.timer) return
58
+ clearInterval(this.timer)
59
+ this.timer = undefined
60
+ if (this.controlTimer) clearInterval(this.controlTimer)
61
+ this.controlTimer = undefined
62
+ if (this.statusTimer) clearInterval(this.statusTimer)
63
+ this.statusTimer = undefined
64
+ }
65
+
66
+ /** True while a ping is in flight — used to block tool calls during a ping. */
67
+ isWarming(sessionID: string): boolean {
68
+ return !!this.store.get(sessionID)?.warming
69
+ }
70
+
71
+ async onEvent(event: any): Promise<void> {
72
+ const type: string | undefined = event?.type
73
+ if (!type) return
74
+
75
+ if (type === "session.deleted") {
76
+ const id = event.properties?.sessionID ?? event.properties?.info?.id
77
+ if (id) {
78
+ this.store.remove(id)
79
+ this.store.persist()
80
+ }
81
+ return
82
+ }
83
+
84
+ if (type === "message.updated") {
85
+ const info = event.properties?.info
86
+ const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
87
+ if (!sessionID) return
88
+ const session = this.store.get(sessionID)
89
+ // Ignore everything that happens inside our own ping turn.
90
+ if (session?.warming) return
91
+ const eventAt = info?.time?.completed ?? info?.time?.created
92
+ // Events are asynchronous: a completed ping may still emit message updates
93
+ // after `warming` clears. Their timestamps predate the recorded ping.
94
+ if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
95
+ return
96
+
97
+ if (info?.role === "user") {
98
+ const s = this.store.ensure(sessionID)
99
+ s.busy = true
100
+ s.active = false
101
+ this.store.persist()
102
+ return
103
+ }
104
+
105
+ if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
106
+ const s = this.store.ensure(sessionID)
107
+ s.lastResponseAt = info.time.completed
108
+ this.store.persist()
109
+ }
110
+ return
111
+ }
112
+
113
+ if (type === "session.status") {
114
+ const sessionID: string | undefined = event.properties?.sessionID
115
+ const status: string | undefined = event.properties?.status?.type
116
+ if (!sessionID || !status) return
117
+
118
+ // The ping's own status transitions must not restart its idle window.
119
+ if (this.store.get(sessionID)?.warming) return
120
+
121
+ if (status === "idle") {
122
+ await this.armWindow(sessionID)
123
+ return
124
+ }
125
+
126
+ const s = this.store.ensure(sessionID)
127
+ s.busy = true
128
+ s.active = false
129
+ this.store.persist()
130
+ return
131
+ }
132
+
133
+ // Compatibility fallback for opencode versions predating session.status.
134
+ if (type === "session.idle") {
135
+ const sessionID: string | undefined = event.properties?.sessionID
136
+ if (!sessionID) return
137
+ if (this.store.get(sessionID)?.warming) return // idle from our own ping
138
+ await this.armWindow(sessionID)
139
+ }
140
+ }
141
+
142
+ private async armWindow(sessionID: string): Promise<void> {
143
+ const s = this.store.ensure(sessionID)
144
+ // Duplicate idle events (including late events from a ping) must not reset the
145
+ // current idle stretch or extend its warm window.
146
+ if (s.active && !s.busy) return
147
+ s.busy = false
148
+ await this.resolveSession(s)
149
+
150
+ // A new turn may have started while session metadata was being resolved.
151
+ if (s.busy || !this.enabled || !s.eligible) {
152
+ s.active = false
153
+ this.store.persist()
154
+ return
155
+ }
156
+
157
+ const now = Date.now()
158
+ if (!s.lastResponseAt) s.lastResponseAt = now
159
+ s.idleSince = now
160
+ s.windowEndsAt = now + this.config.windowMs
161
+ s.nextPingAt = now + this.config.intervalMs
162
+ s.pingsSent = 0
163
+ s.lastPing = undefined
164
+ s.active = true
165
+ this.store.persist()
166
+ this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
167
+ }
168
+
169
+ private async tick(): Promise<void> {
170
+ if (!this.enabled) return
171
+ const now = Date.now()
172
+ for (const s of this.store.all()) {
173
+ if (!s.eligible || !s.active || s.busy || s.warming) continue
174
+
175
+ if (now >= s.windowEndsAt) {
176
+ s.active = false
177
+ this.store.persist()
178
+ this.logger.dbg(
179
+ `window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) — cache may cool`,
180
+ )
181
+ continue
182
+ }
183
+
184
+ if (now >= s.nextPingAt) void this.firePing(s.sessionID)
185
+ }
186
+ }
187
+
188
+ private async firePing(sessionID: string): Promise<void> {
189
+ const s = this.store.get(sessionID)
190
+ if (!s || s.warming || s.busy) return
191
+
192
+ s.warming = true
193
+ this.store.persist()
194
+ const startAt = Date.now()
195
+
196
+ try {
197
+ const res = await this.client.session.prompt({
198
+ path: { id: sessionID },
199
+ body: { parts: [{ type: "text", text: this.config.pingToken }] },
200
+ })
201
+
202
+ const info = res?.data?.info ?? res?.info
203
+ const cache = info?.tokens?.cache ?? {}
204
+ const record = {
205
+ at: Date.now(),
206
+ hit: Number(cache.read ?? 0) > 0,
207
+ input: Number(info?.tokens?.input ?? 0),
208
+ cacheRead: Number(cache.read ?? 0),
209
+ cacheWrite: Number(cache.write ?? 0),
210
+ output: Number(info?.tokens?.output ?? 0),
211
+ }
212
+ s.lastPing = record
213
+ s.lastPingAt = record.at
214
+ s.pingsSent += 1
215
+ this.logger.info(
216
+ `ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} ` +
217
+ `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
218
+ )
219
+
220
+ if (this.config.revertPing) await this.revertPing(sessionID, startAt)
221
+ } catch (err) {
222
+ this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
223
+ } finally {
224
+ s.warming = false
225
+ s.nextPingAt = Date.now() + jitter(this.config.intervalMs)
226
+ this.store.persist()
227
+ }
228
+ }
229
+
230
+ /** Remove the `~` user + `~` assistant turn so it never enters real context. */
231
+ private async revertPing(sessionID: string, startAt: number): Promise<void> {
232
+ let messages: any[]
233
+ try {
234
+ const res = await this.client.session.messages({ path: { id: sessionID } })
235
+ messages = res?.data ?? res ?? []
236
+ } catch (err) {
237
+ this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))
238
+ return
239
+ }
240
+
241
+ let target: string | undefined
242
+ for (const m of messages) {
243
+ if (m?.info?.role !== "user") continue
244
+ if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue
245
+ const text = (m.parts ?? [])
246
+ .filter((p: any) => p?.type === "text")
247
+ .map((p: any) => p?.text ?? "")
248
+ .join("")
249
+ .trim()
250
+ if (text === this.config.pingToken) target = m.info.id
251
+ }
252
+
253
+ if (!target) {
254
+ this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
255
+ return
256
+ }
257
+
258
+ try {
259
+ await this.client.session.revert({
260
+ path: { id: sessionID },
261
+ body: { messageID: target },
262
+ })
263
+ } catch (err) {
264
+ // BusyError (a real turn started) or similar — leave the ping visible.
265
+ this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err))
266
+ }
267
+ }
268
+
269
+ private async resolveSession(s: SessionKeepalive): Promise<void> {
270
+ try {
271
+ const res = await this.client.session.get({ path: { id: s.sessionID } })
272
+ const info = res?.data ?? res
273
+ if (info?.parentID && !this.config.includeChildSessions) {
274
+ s.eligible = false
275
+ s.modelLabel = info?.model?.id ?? "child"
276
+ return
277
+ }
278
+ s.modelLabel = info?.model?.id ?? "unknown"
279
+ s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id)
280
+ } catch (err) {
281
+ s.eligible = false
282
+ this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err))
283
+ }
284
+ }
285
+
286
+ private pollControl(): void {
287
+ const control = readControl()
288
+ if (!control || control.updatedAt <= this.controlUpdatedAt) return
289
+ this.controlUpdatedAt = control.updatedAt
290
+ if (control.enabled === this.enabled) return
291
+
292
+ this.enabled = control.enabled
293
+ this.store.setEnabled(control.enabled)
294
+ const now = Date.now()
295
+ for (const session of this.store.all()) {
296
+ if (!control.enabled) {
297
+ session.active = false
298
+ continue
299
+ }
300
+ if (!session.eligible || session.busy || session.warming) continue
301
+ if (!session.lastResponseAt) session.lastResponseAt = now
302
+ session.idleSince = now
303
+ session.windowEndsAt = now + this.config.windowMs
304
+ session.nextPingAt = now + this.config.intervalMs
305
+ session.pingsSent = 0
306
+ session.lastPing = undefined
307
+ session.active = true
308
+ }
309
+ this.store.persist()
310
+ this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
311
+ }
312
+
313
+ /** Recover when a session.status idle event is missed by reconciling with the API. */
314
+ private async reconcileStatus(): Promise<void> {
315
+ if (this.reconcilingStatus) return
316
+ const busy = this.store.all().filter((session) => session.busy && !session.warming)
317
+ if (busy.length === 0) return
318
+
319
+ this.reconcilingStatus = true
320
+ try {
321
+ const res = await this.client.session.status()
322
+ const statuses = res?.data ?? res ?? {}
323
+ for (const session of busy) {
324
+ const status = statuses?.[session.sessionID]?.type ?? "idle"
325
+ if (status === "idle") await this.armWindow(session.sessionID)
326
+ }
327
+ } catch (err) {
328
+ this.logger.dbg("status reconciliation failed", errText(err))
329
+ } finally {
330
+ this.reconcilingStatus = false
331
+ }
332
+ }
333
+ }
334
+
335
+ function short(sessionID: string): string {
336
+ return sessionID.slice(-6)
337
+ }
338
+
339
+ function jitter(base: number): number {
340
+ return Math.max(1_000, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS))
341
+ }
342
+
343
+ function errText(err: unknown): string {
344
+ return err instanceof Error ? err.message : String(err)
345
+ }
package/lib/logger.ts ADDED
@@ -0,0 +1,21 @@
1
+ const PREFIX = "[cache-keepalive]"
2
+
3
+ export class Logger {
4
+ constructor(private readonly debug: boolean) {}
5
+
6
+ info(message: string, data?: unknown) {
7
+ if (this.debug) console.log(`${PREFIX} ${message}`, data ?? "")
8
+ }
9
+
10
+ warn(message: string, data?: unknown) {
11
+ console.warn(`${PREFIX} ${message}`, data ?? "")
12
+ }
13
+
14
+ error(message: string, data?: unknown) {
15
+ console.error(`${PREFIX} ${message}`, data ?? "")
16
+ }
17
+
18
+ dbg(message: string, data?: unknown) {
19
+ if (this.debug) console.log(`${PREFIX}:debug ${message}`, data ?? "")
20
+ }
21
+ }
package/lib/model.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { KeepaliveConfig } from "./config"
2
+
3
+ /**
4
+ * Warming only helps on providers that do prefix caching we can refresh by
5
+ * replaying the prefix. Supported defaults include Claude and GPT models served
6
+ * through GitHub Copilot. Everything else is skipped.
7
+ */
8
+ export function isEligibleModel(
9
+ config: KeepaliveConfig,
10
+ providerID: string | undefined,
11
+ modelID: string | undefined,
12
+ ): boolean {
13
+ if (!providerID || !modelID) return false
14
+ const provider = providerID.toLowerCase()
15
+ const model = modelID.toLowerCase()
16
+ const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle))
17
+ const modelOk = config.modelAllowlist.some((needle) => model.includes(needle))
18
+ return providerOk && modelOk
19
+ }
package/lib/paths.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ /**
6
+ * Absolute path to the JSON state file shared between the server plugin (writer)
7
+ * and the TUI plugin (reader). Both processes run on the same machine, so a file
8
+ * under opencode's plugin storage dir is the simplest cross-process channel.
9
+ *
10
+ * Mirrors the storage layout used by other opencode plugins:
11
+ * <XDG_DATA_HOME | ~/.local/share>/opencode/storage/plugin/keepalive/state.json
12
+ */
13
+ export function stateFilePath(directory: string): string {
14
+ const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)
15
+ return join(stateDirectoryPath(), `state-${key}.json`)
16
+ }
17
+
18
+ export function stateDirectoryPath(): string {
19
+ return join(
20
+ process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
21
+ "opencode",
22
+ "storage",
23
+ "plugin",
24
+ "keepalive",
25
+ )
26
+ }
27
+
28
+ /** Runtime on/off control written by the TUI and read by the server plugin. */
29
+ export function controlFilePath(): string {
30
+ return join(stateDirectoryPath(), "control.json")
31
+ }
package/lib/state.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { mkdir, rename, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+ import type { KeepaliveConfig } from "./config"
4
+ import { stateFilePath } from "./paths"
5
+
6
+ export type PingRecord = {
7
+ at: number
8
+ hit: boolean
9
+ input: number
10
+ cacheRead: number
11
+ cacheWrite: number
12
+ output: number
13
+ }
14
+
15
+ /** Live per-session bookkeeping held by the server plugin. */
16
+ export type SessionKeepalive = {
17
+ sessionID: string
18
+ /** Whether the session's provider/model pair supports cache warming. */
19
+ eligible: boolean
20
+ modelLabel?: string
21
+ /** Epoch ms of the last real assistant response (excludes ping replies). */
22
+ lastResponseAt: number
23
+ /** Epoch ms the current idle stretch began. */
24
+ idleSince: number
25
+ /** Epoch ms after which warming stops and the cache is allowed to go cold. */
26
+ windowEndsAt: number
27
+ /** Epoch ms the next ping is due. */
28
+ nextPingAt: number
29
+ /** Pings sent during the current idle stretch. */
30
+ pingsSent: number
31
+ /** A real turn is currently running. */
32
+ busy: boolean
33
+ /** A keepalive ping is currently in flight. */
34
+ warming: boolean
35
+ /** Within the warm window and actively scheduling pings. */
36
+ active: boolean
37
+ /** Epoch ms of the last ping; retained only to ignore its late events. */
38
+ lastPingAt?: number
39
+ lastPing?: PingRecord
40
+ }
41
+
42
+ /** Lean projection persisted to disk for the TUI reader. */
43
+ export type PersistedSession = {
44
+ eligible: boolean
45
+ modelLabel?: string
46
+ lastResponseAt: number
47
+ idleSince: number
48
+ windowEndsAt: number
49
+ nextPingAt: number
50
+ intervalMs: number
51
+ pingsSent: number
52
+ busy: boolean
53
+ warming: boolean
54
+ active: boolean
55
+ lastPing?: PingRecord
56
+ }
57
+
58
+ export type PersistedState = {
59
+ version: 1
60
+ updatedAt: number
61
+ enabled: boolean
62
+ intervalMs: number
63
+ windowMs: number
64
+ sessions: Record<string, PersistedSession>
65
+ }
66
+
67
+ export class KeepaliveStore {
68
+ private readonly sessions = new Map<string, SessionKeepalive>()
69
+ private writeQueue: Promise<void> = Promise.resolve()
70
+ private enabled: boolean
71
+
72
+ constructor(
73
+ private readonly config: KeepaliveConfig,
74
+ private readonly directory: string,
75
+ ) {
76
+ this.enabled = config.enabled
77
+ }
78
+
79
+ setEnabled(enabled: boolean): void {
80
+ this.enabled = enabled
81
+ }
82
+
83
+ get(sessionID: string): SessionKeepalive | undefined {
84
+ return this.sessions.get(sessionID)
85
+ }
86
+
87
+ ensure(sessionID: string): SessionKeepalive {
88
+ const existing = this.sessions.get(sessionID)
89
+ if (existing) return existing
90
+ const created: SessionKeepalive = {
91
+ sessionID,
92
+ eligible: false,
93
+ lastResponseAt: 0,
94
+ idleSince: 0,
95
+ windowEndsAt: 0,
96
+ nextPingAt: 0,
97
+ pingsSent: 0,
98
+ busy: false,
99
+ warming: false,
100
+ active: false,
101
+ }
102
+ this.sessions.set(sessionID, created)
103
+ return created
104
+ }
105
+
106
+ remove(sessionID: string): void {
107
+ this.sessions.delete(sessionID)
108
+ }
109
+
110
+ all(): SessionKeepalive[] {
111
+ return [...this.sessions.values()]
112
+ }
113
+
114
+ /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
115
+ persist(): void {
116
+ const snapshot: PersistedState = {
117
+ version: 1,
118
+ updatedAt: Date.now(),
119
+ enabled: this.enabled,
120
+ intervalMs: this.config.intervalMs,
121
+ windowMs: this.config.windowMs,
122
+ sessions: Object.fromEntries(
123
+ this.all().map((s) => [
124
+ s.sessionID,
125
+ {
126
+ eligible: s.eligible,
127
+ modelLabel: s.modelLabel,
128
+ lastResponseAt: s.lastResponseAt,
129
+ idleSince: s.idleSince,
130
+ windowEndsAt: s.windowEndsAt,
131
+ nextPingAt: s.nextPingAt,
132
+ intervalMs: this.config.intervalMs,
133
+ pingsSent: s.pingsSent,
134
+ busy: s.busy,
135
+ warming: s.warming,
136
+ active: s.active,
137
+ lastPing: s.lastPing,
138
+ } satisfies PersistedSession,
139
+ ]),
140
+ ),
141
+ }
142
+
143
+ const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
144
+ this.writeQueue = this.writeQueue.then(write, write)
145
+ }
146
+ }
147
+
148
+ async function atomicWrite(path: string, contents: string): Promise<void> {
149
+ try {
150
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
151
+ await mkdir(dirname(path), { recursive: true })
152
+ await writeFile(tmp, `${contents}\n`, "utf8")
153
+ await rename(tmp, path)
154
+ } catch {
155
+ // Persistence is best-effort; the TUI readout simply falls behind.
156
+ }
157
+ }
package/lib/system.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stable keepalive instruction appended to the system prompt.
3
+ *
4
+ * IMPORTANT: this text must be byte-stable and applied to *every* request for an
5
+ * eligible model (real turns and pings alike). If it were only added on pings, the
6
+ * system prefix would differ from real turns and the ping would miss the cache.
7
+ * Adding it consistently keeps the cached prefix identical and lets the model
8
+ * answer a ping with a single token instead of a paragraph or a tool call.
9
+ */
10
+ export function keepaliveInstruction(pingToken: string): string {
11
+ return [
12
+ "<cache_keepalive>",
13
+ `A user message consisting of exactly the single token "${pingToken}" is an`,
14
+ "automated cache-keepalive ping, not a real request. When you receive it,",
15
+ `reply with exactly "${pingToken}" and nothing else. Do not call any tools,`,
16
+ "do not explain, and do not take any action.",
17
+ "</cache_keepalive>",
18
+ ].join("\n")
19
+ }
@@ -0,0 +1,54 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+ import { readControl, writeControl } from "../control"
3
+
4
+ export function registerKeepaliveCommands(api: TuiPluginApi): void {
5
+ const setEnabled = async (enabled: boolean) => {
6
+ try {
7
+ await writeControl(enabled)
8
+ api.ui.toast({
9
+ variant: enabled ? "success" : "warning",
10
+ title: "Cache keepalive",
11
+ message: enabled ? "Enabled" : "Disabled",
12
+ })
13
+ } catch (error) {
14
+ api.ui.toast({
15
+ variant: "error",
16
+ title: "Cache keepalive",
17
+ message: error instanceof Error ? error.message : String(error),
18
+ })
19
+ }
20
+ }
21
+
22
+ api.keymap.registerLayer({
23
+ commands: [
24
+ {
25
+ name: "keepalive.toggle",
26
+ title: "Toggle cache keepalive",
27
+ category: "Keepalive",
28
+ namespace: "palette",
29
+ slashName: "keepalive-toggle",
30
+ run: () => setEnabled(!currentEnabled()),
31
+ },
32
+ {
33
+ name: "keepalive.on",
34
+ title: "Enable cache keepalive",
35
+ category: "Keepalive",
36
+ namespace: "palette",
37
+ slashName: "keepalive-on",
38
+ run: () => setEnabled(true),
39
+ },
40
+ {
41
+ name: "keepalive.off",
42
+ title: "Disable cache keepalive",
43
+ category: "Keepalive",
44
+ namespace: "palette",
45
+ slashName: "keepalive-off",
46
+ run: () => setEnabled(false),
47
+ },
48
+ ],
49
+ })
50
+ }
51
+
52
+ function currentEnabled(): boolean {
53
+ return readControl()?.enabled ?? true
54
+ }