@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.6

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.
package/lib/state.ts CHANGED
@@ -12,6 +12,40 @@ export type PingRecord = {
12
12
  output: number
13
13
  }
14
14
 
15
+ /**
16
+ * Request settings of the last real user turn. A ping must replay them exactly:
17
+ * a prompt without explicit settings runs as opencode's default agent with no
18
+ * variant, which changes the request shape (missing the cache) and overwrites
19
+ * the session's persisted agent/model/variant.
20
+ */
21
+ export type PingRequest = {
22
+ agent?: string
23
+ providerID: string
24
+ modelID: string
25
+ variant?: string
26
+ }
27
+
28
+ /** Bookkeeping for the ping request currently in flight. */
29
+ export type InflightPing = {
30
+ /** Epoch ms the ping attempt began (before the pre-flight status check). */
31
+ startedAt: number
32
+ /** The prompt request has been issued. */
33
+ sent: boolean
34
+ /** The ping's own user message, identified by its ping-token text part. */
35
+ messageID?: string
36
+ /**
37
+ * New user messages seen during the ping whose text has not yet been observed.
38
+ * Each is classified by its first text part: the ping token marks our own
39
+ * message, anything else a real turn.
40
+ */
41
+ pending: Map<string, unknown>
42
+ /**
43
+ * A real user message appeared while the ping was in flight. opencode joins such
44
+ * a prompt to the running ping loop, so the rest of the run is real work.
45
+ */
46
+ interrupted: boolean
47
+ }
48
+
15
49
  /** Live per-session bookkeeping held by the server plugin. */
16
50
  export type SessionKeepalive = {
17
51
  sessionID: string
@@ -30,15 +64,20 @@ export type SessionKeepalive = {
30
64
  pingsSent: number
31
65
  /** A real turn is currently running. */
32
66
  busy: boolean
33
- /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
34
- busySince?: number
35
67
  /** A keepalive ping is currently in flight. */
36
68
  warming: boolean
69
+ /** The in-flight ping; set exactly while `warming` is true. */
70
+ ping?: InflightPing
71
+ /** ID and creation time of the newest user message seen, to tell new turns from re-emitted old messages. */
72
+ lastUserID?: string
73
+ lastUserAt?: number
74
+ /** Settings of the last real user turn, replayed by pings. */
75
+ request?: PingRequest
37
76
  /** Within the warm window and actively scheduling pings. */
38
77
  active: boolean
39
78
  /** True while `armWindow` is resolving session metadata (async guard). */
40
79
  arming?: boolean
41
- /** Epoch ms of the last ping; retained only to ignore its late events. */
80
+ /** Epoch ms the last sent ping finished; ignores its late events and marks the last cache touch. */
42
81
  lastPingAt?: number
43
82
  lastPing?: PingRecord
44
83
  }
@@ -72,18 +111,25 @@ export class KeepaliveStore {
72
111
  private readonly sessions = new Map<string, SessionKeepalive>()
73
112
  private writeQueue: Promise<void> = Promise.resolve()
74
113
  private enabled: boolean
114
+ private intervalMs: number
75
115
 
76
116
  constructor(
77
117
  private readonly config: KeepaliveConfig,
78
118
  private readonly directory: string,
79
119
  ) {
80
120
  this.enabled = config.enabled
121
+ this.intervalMs = config.intervalMs
81
122
  }
82
123
 
83
124
  setEnabled(enabled: boolean): void {
84
125
  this.enabled = enabled
85
126
  }
86
127
 
128
+ /** Effective ping interval (runtime override or config) reported to the TUI. */
129
+ setIntervalMs(intervalMs: number): void {
130
+ this.intervalMs = intervalMs
131
+ }
132
+
87
133
  get(sessionID: string): SessionKeepalive | undefined {
88
134
  return this.sessions.get(sessionID)
89
135
  }
@@ -115,13 +161,16 @@ export class KeepaliveStore {
115
161
  return [...this.sessions.values()]
116
162
  }
117
163
 
118
- /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
119
- persist(): void {
164
+ /**
165
+ * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
166
+ * returned promise resolves once this snapshot and every earlier one are written.
167
+ */
168
+ persist(): Promise<void> {
120
169
  const snapshot: PersistedState = {
121
170
  version: 1,
122
171
  updatedAt: Date.now(),
123
172
  enabled: this.enabled,
124
- intervalMs: this.config.intervalMs,
173
+ intervalMs: this.intervalMs,
125
174
  windowMs: this.config.windowMs,
126
175
  sessions: Object.fromEntries(
127
176
  this.all().map((s) => [
@@ -133,7 +182,7 @@ export class KeepaliveStore {
133
182
  idleSince: s.idleSince,
134
183
  windowEndsAt: s.windowEndsAt,
135
184
  nextPingAt: s.nextPingAt,
136
- intervalMs: this.config.intervalMs,
185
+ intervalMs: this.intervalMs,
137
186
  pingsSent: s.pingsSent,
138
187
  busy: s.busy,
139
188
  warming: s.warming,
@@ -146,6 +195,7 @@ export class KeepaliveStore {
146
195
 
147
196
  const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
148
197
  this.writeQueue = this.writeQueue.then(write, write)
198
+ return this.writeQueue
149
199
  }
150
200
  }
151
201
 
@@ -0,0 +1,184 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiDialogSelectOption, TuiPluginApi } from "@opencode-ai/plugin/tui"
4
+ import { readFileSync } from "node:fs"
5
+ import {
6
+ type ControlPatch,
7
+ isValidInterval,
8
+ MAX_INTERVAL_MS,
9
+ MIN_INTERVAL_MS,
10
+ readControl,
11
+ updateControl,
12
+ } from "../control"
13
+ import { stateFilePath } from "../paths"
14
+ import { formatDuration, parseDuration } from "./format"
15
+
16
+ /** Interval presets matched to common provider prompt-cache lifetimes. */
17
+ const INTERVAL_PRESETS: { ms: number; description: string }[] = [
18
+ { ms: 270_000, description: "5-minute caches (Claude / Anthropic)" },
19
+ { ms: 1_700_000, description: "30-minute caches (GPT / OpenAI)" },
20
+ ]
21
+
22
+ type IntervalChoice = number | "config" | "custom"
23
+
24
+ /**
25
+ * Register the keepalive commands. The control file is scoped per directory, so
26
+ * toggling keepalive or changing its interval here only affects this project's
27
+ * server plugin — not other open opencode sessions. The server polls the file
28
+ * every second, so changes apply without a restart.
29
+ */
30
+ export function registerKeepaliveCommands(api: TuiPluginApi): void {
31
+ // Read lazily: state paths may not be populated at registration time.
32
+ const directory = () => api.state.path.directory
33
+
34
+ const apply = async (
35
+ patch: ControlPatch,
36
+ message: string,
37
+ variant: "success" | "warning" | "info",
38
+ ) => {
39
+ try {
40
+ await updateControl(directory(), patch)
41
+ api.ui.toast({ variant, title: "Cache keepalive", message })
42
+ } catch (error) {
43
+ api.ui.toast({
44
+ variant: "error",
45
+ title: "Cache keepalive",
46
+ message: error instanceof Error ? error.message : String(error),
47
+ })
48
+ }
49
+ }
50
+
51
+ const setEnabled = (enabled: boolean) =>
52
+ apply({ enabled }, enabled ? "Enabled" : "Disabled", enabled ? "success" : "warning")
53
+
54
+ const setPingInterval = (intervalMs: number | null) =>
55
+ apply(
56
+ { intervalMs },
57
+ intervalMs === null
58
+ ? "Ping interval reset to the plugin default"
59
+ : `Ping interval ${formatDuration(intervalMs)}`,
60
+ "info",
61
+ )
62
+
63
+ const showCustomInterval = (current: number | undefined) => {
64
+ api.ui.dialog.replace(() => (
65
+ <api.ui.DialogPrompt
66
+ title="Custom ping interval"
67
+ placeholder="e.g. 4m30s, 270s, 12m (a bare number means minutes)"
68
+ value={current ? formatDuration(current).replace(/ /g, "") : undefined}
69
+ onConfirm={(raw) => {
70
+ const ms = parseDuration(raw)
71
+ if (ms === undefined || !isValidInterval(ms)) {
72
+ api.ui.toast({
73
+ variant: "error",
74
+ title: "Cache keepalive",
75
+ message: `Enter a duration between ${formatDuration(MIN_INTERVAL_MS)} and ${formatDuration(MAX_INTERVAL_MS)}, e.g. 4m30s`,
76
+ })
77
+ return
78
+ }
79
+ api.ui.dialog.clear()
80
+ void setPingInterval(ms)
81
+ }}
82
+ onCancel={() => api.ui.dialog.clear()}
83
+ />
84
+ ))
85
+ }
86
+
87
+ const showInterval = () => {
88
+ const override = readControl(directory())?.intervalMs
89
+ const options: TuiDialogSelectOption<IntervalChoice>[] = INTERVAL_PRESETS.map((preset) => ({
90
+ title: formatDuration(preset.ms),
91
+ value: preset.ms,
92
+ description: preset.description,
93
+ }))
94
+ if (override !== undefined && !INTERVAL_PRESETS.some((preset) => preset.ms === override)) {
95
+ options.push({
96
+ title: formatDuration(override),
97
+ value: override,
98
+ description: "Current custom interval",
99
+ })
100
+ }
101
+ options.push(
102
+ {
103
+ title: "Custom…",
104
+ value: "custom",
105
+ description: "Enter a duration, e.g. 4m30s or 12m",
106
+ },
107
+ {
108
+ title: "Plugin default",
109
+ value: "config",
110
+ description: "Clear the override and use the configured intervalMs",
111
+ },
112
+ )
113
+
114
+ api.ui.dialog.replace(() => (
115
+ <api.ui.DialogSelect
116
+ title="Keepalive ping interval"
117
+ options={options}
118
+ current={override ?? "config"}
119
+ onSelect={(option) => {
120
+ const choice = option.value as IntervalChoice
121
+ if (choice === "custom") {
122
+ showCustomInterval(override)
123
+ return
124
+ }
125
+ api.ui.dialog.clear()
126
+ void setPingInterval(choice === "config" ? null : choice)
127
+ }}
128
+ />
129
+ ))
130
+ }
131
+
132
+ api.keymap.registerLayer({
133
+ commands: [
134
+ {
135
+ name: "keepalive.toggle",
136
+ title: "Toggle cache keepalive",
137
+ category: "Keepalive",
138
+ namespace: "palette",
139
+ slashName: "keepalive-toggle",
140
+ run: () => setEnabled(!currentEnabled(directory())),
141
+ },
142
+ {
143
+ name: "keepalive.on",
144
+ title: "Enable cache keepalive",
145
+ category: "Keepalive",
146
+ namespace: "palette",
147
+ slashName: "keepalive-on",
148
+ run: () => setEnabled(true),
149
+ },
150
+ {
151
+ name: "keepalive.off",
152
+ title: "Disable cache keepalive",
153
+ category: "Keepalive",
154
+ namespace: "palette",
155
+ slashName: "keepalive-off",
156
+ run: () => setEnabled(false),
157
+ },
158
+ {
159
+ name: "keepalive.interval",
160
+ title: "Set cache keepalive ping interval",
161
+ category: "Keepalive",
162
+ namespace: "palette",
163
+ slashName: "keepalive-interval",
164
+ run: showInterval,
165
+ },
166
+ ],
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Effective on/off state: the runtime override if set, else what this project's
172
+ * server last reported (its configured default), else on.
173
+ */
174
+ function currentEnabled(directory: string): boolean {
175
+ const override = readControl(directory)?.enabled
176
+ if (override !== undefined) return override
177
+ try {
178
+ const state = JSON.parse(readFileSync(stateFilePath(directory), "utf8"))
179
+ if (typeof state?.enabled === "boolean") return state.enabled
180
+ } catch {
181
+ // No server snapshot yet.
182
+ }
183
+ return true
184
+ }
@@ -6,7 +6,7 @@ import { readdirSync, readFileSync } from "node:fs"
6
6
  import { join } from "node:path"
7
7
  import { stateDirectoryPath, stateFilePath } from "../paths"
8
8
  import type { PersistedSession, PersistedState, PingRecord } from "../state"
9
- import { kfmt, mmss } from "./format"
9
+ import { formatDuration, kfmt, mmss } from "./format"
10
10
 
11
11
  type Theme = Record<string, any>
12
12
  type Status = "waiting" | "off" | "warming" | "busy" | "armed" | "cold"
@@ -16,6 +16,8 @@ type Derived = {
16
16
  left: number
17
17
  status: Status
18
18
  lastPing?: PingRecord
19
+ /** Effective ping interval for this project, when the server has reported it. */
20
+ intervalMs?: number
19
21
  }
20
22
 
21
23
  /**
@@ -60,12 +62,25 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
60
62
  const snapshot = state()
61
63
  const sessionID = props.sessionID
62
64
  if (!snapshot || !sessionID) {
63
- return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
65
+ return {
66
+ idleMs: 0,
67
+ sent: 0,
68
+ left: 0,
69
+ status: "waiting",
70
+ intervalMs: snapshot?.intervalMs,
71
+ }
64
72
  }
65
73
  const session: PersistedSession | undefined = snapshot.sessions?.[sessionID]
66
- if (!session) return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
67
-
68
- const interval = session.intervalMs || snapshot.intervalMs || 270_000
74
+ if (!session)
75
+ return {
76
+ idleMs: 0,
77
+ sent: 0,
78
+ left: 0,
79
+ status: "waiting",
80
+ intervalMs: snapshot.intervalMs,
81
+ }
82
+
83
+ const interval = snapshot.intervalMs || session.intervalMs || 270_000
69
84
  const idleMs = Math.max(0, now() - (session.lastResponseAt || now()))
70
85
  // `nextPingAt` is the scheduler's source of truth and includes the
71
86
  // per-ping jitter. Counting from it keeps the two counters in step.
@@ -84,7 +99,14 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
84
99
  ? "armed"
85
100
  : "cold"
86
101
 
87
- return { idleMs, sent: session.pingsSent || 0, left, status, lastPing: session.lastPing }
102
+ return {
103
+ idleMs,
104
+ sent: session.pingsSent || 0,
105
+ left,
106
+ status,
107
+ lastPing: session.lastPing,
108
+ intervalMs: interval,
109
+ }
88
110
  }
89
111
 
90
112
  const theme = props.theme
@@ -100,6 +122,11 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
100
122
  <text fg={statusColor(theme, data().status)}>
101
123
  {statusLabel(data().status)}
102
124
  </text>
125
+ <Show when={data().intervalMs}>
126
+ {(interval: () => number) => (
127
+ <text fg={theme.textMuted}>every {formatDuration(interval())}</text>
128
+ )}
129
+ </Show>
103
130
  </box>
104
131
 
105
132
  <box flexDirection="row" gap={1}>
package/lib/tui/format.ts CHANGED
@@ -11,3 +11,39 @@ export function kfmt(n: number): string {
11
11
  const k = n / 1000
12
12
  return `${k >= 10 ? Math.round(k) : k.toFixed(1)}k`
13
13
  }
14
+
15
+ const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1_000 }
16
+
17
+ /**
18
+ * Parse a human duration such as "4m30s", "4.5m", "270s", "1h" or "28m 20s".
19
+ * A bare number means minutes. Returns undefined for anything unparseable;
20
+ * range checks are the caller's concern.
21
+ */
22
+ export function parseDuration(input: string): number | undefined {
23
+ const text = input.trim().toLowerCase()
24
+ if (!text) return undefined
25
+ if (/^\d+(?:\.\d+)?$/.test(text)) return Math.round(Number(text) * UNIT_MS.m)
26
+
27
+ const segment = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*/y
28
+ let total = 0
29
+ while (segment.lastIndex < text.length) {
30
+ const match = segment.exec(text)
31
+ if (!match) return undefined
32
+ total += Number(match[1]) * UNIT_MS[match[2][0]]
33
+ }
34
+ return Math.round(total)
35
+ }
36
+
37
+ /** Compact duration label, e.g. 270000 -> "4m 30s", 3600000 -> "1h". */
38
+ export function formatDuration(ms: number): string {
39
+ const total = Math.max(0, Math.round(ms / 1000))
40
+ const hours = Math.floor(total / 3600)
41
+ const minutes = Math.floor((total % 3600) / 60)
42
+ const seconds = total % 60
43
+ const parts = [
44
+ hours ? `${hours}h` : "",
45
+ minutes ? `${minutes}m` : "",
46
+ seconds ? `${seconds}s` : "",
47
+ ].filter(Boolean)
48
+ return parts.length ? parts.join(" ") : "0s"
49
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@vikrant82/opencode-cache-keepalive",
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "description": "OpenCode plugin that keeps provider prompt caches warm by sending invisible keepalive pings, with a live TUI readout",
7
7
  "main": "./dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "clean": "rm -rf dist",
25
25
  "build": "npm run clean && tsup && tsc --emitDeclarationOnly",
26
26
  "dev": "opencode plugin dev",
27
+ "test": "node --import tsx --test test/*.test.ts",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "format": "prettier --write .",
29
30
  "format:check": "prettier --check ."
@@ -59,6 +60,7 @@
59
60
  "prettier": "^3.4.0",
60
61
  "solid-js": "^1.9.12",
61
62
  "tsup": "^8.5.1",
63
+ "tsx": "^4.23.15",
62
64
  "typescript": "^5.7.0"
63
65
  },
64
66
  "files": [
@@ -1,59 +0,0 @@
1
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
- import { readControl, writeControl } from "../control"
3
-
4
- export function registerKeepaliveCommands(api: TuiPluginApi): void {
5
- // The control file is scoped per directory, so toggling keepalive here only
6
- // affects this project's server plugin — not other open opencode sessions.
7
- // Read lazily: state paths may not be populated at registration time.
8
- const directory = () => api.state.path.directory
9
-
10
- const setEnabled = async (enabled: boolean) => {
11
- try {
12
- await writeControl(enabled, directory())
13
- api.ui.toast({
14
- variant: enabled ? "success" : "warning",
15
- title: "Cache keepalive",
16
- message: enabled ? "Enabled" : "Disabled",
17
- })
18
- } catch (error) {
19
- api.ui.toast({
20
- variant: "error",
21
- title: "Cache keepalive",
22
- message: error instanceof Error ? error.message : String(error),
23
- })
24
- }
25
- }
26
-
27
- api.keymap.registerLayer({
28
- commands: [
29
- {
30
- name: "keepalive.toggle",
31
- title: "Toggle cache keepalive",
32
- category: "Keepalive",
33
- namespace: "palette",
34
- slashName: "keepalive-toggle",
35
- run: () => setEnabled(!currentEnabled(directory())),
36
- },
37
- {
38
- name: "keepalive.on",
39
- title: "Enable cache keepalive",
40
- category: "Keepalive",
41
- namespace: "palette",
42
- slashName: "keepalive-on",
43
- run: () => setEnabled(true),
44
- },
45
- {
46
- name: "keepalive.off",
47
- title: "Disable cache keepalive",
48
- category: "Keepalive",
49
- namespace: "palette",
50
- slashName: "keepalive-off",
51
- run: () => setEnabled(false),
52
- },
53
- ],
54
- })
55
- }
56
-
57
- function currentEnabled(directory: string): boolean {
58
- return readControl(directory)?.enabled ?? true
59
- }