@vikrant82/opencode-cache-keepalive 0.1.0 → 0.1.2

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.
@@ -21,14 +21,18 @@ export type SessionKeepalive = {
21
21
  windowEndsAt: number;
22
22
  /** Epoch ms the next ping is due. */
23
23
  nextPingAt: number;
24
- /** Pings sent during the current idle stretch. */
24
+ /** Number of scheduled ping requests attempted during this warm window. */
25
25
  pingsSent: number;
26
26
  /** A real turn is currently running. */
27
27
  busy: boolean;
28
+ /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
29
+ busySince?: number;
28
30
  /** A keepalive ping is currently in flight. */
29
31
  warming: boolean;
30
32
  /** Within the warm window and actively scheduling pings. */
31
33
  active: boolean;
34
+ /** True while `armWindow` is resolving session metadata (async guard). */
35
+ arming?: boolean;
32
36
  /** Epoch ms of the last ping; retained only to ignore its late events. */
33
37
  lastPingAt?: number;
34
38
  lastPing?: PingRecord;
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,OAAO,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,8DAA8D;AAC9D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAA;IACtB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAA;IACjB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAA;IACpB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAA;IACjB,wCAAwC;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAA;IACf,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,CAAC,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAC7C,CAAA;AAED,qBAAa,cAAc;IAMnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAN9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsC;IAC/D,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,MAAM;IAKtC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIlC,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIpD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAmB3C,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,GAAG,IAAI,gBAAgB,EAAE;IAIzB,+EAA+E;IAC/E,OAAO,IAAI,IAAI;CA+BlB"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,OAAO,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,8DAA8D;AAC9D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAA;IACtB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAA;IACjB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAA;IACpB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAA;IACjB,wCAAwC;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAA;IACf,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,CAAC,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAC7C,CAAA;AAED,qBAAa,cAAc;IAMnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAN9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsC;IAC/D,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,MAAM;IAKtC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIlC,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIpD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAmB3C,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,GAAG,IAAI,gBAAgB,EAAE;IAIzB,+EAA+E;IAC/E,OAAO,IAAI,IAAI;CA+BlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../lib/tui/footer.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAUtC,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAUhC;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,OAwG7F"}
1
+ {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../lib/tui/footer.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAUtC,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAUhC;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,OA+G7F"}
package/lib/keepalive.ts CHANGED
@@ -8,6 +8,9 @@ const TICK_MS = 15_000
8
8
  const JITTER_MS = 15_000
9
9
  const CONTROL_POLL_MS = 1_000
10
10
  const STATUS_POLL_MS = 2_000
11
+ const PING_STATUS_GRACE_MS = 5_000
12
+ /** Backstop: clear a stuck `busy` flag after this long without an idle event. */
13
+ const BUSY_TIMEOUT_MS = 300_000
11
14
 
12
15
  /**
13
16
  * The keepalive engine.
@@ -97,6 +100,7 @@ export class KeepaliveEngine {
97
100
  if (info?.role === "user") {
98
101
  const s = this.store.ensure(sessionID)
99
102
  s.busy = true
103
+ s.busySince = Date.now()
100
104
  s.active = false
101
105
  this.store.persist()
102
106
  return
@@ -115,8 +119,17 @@ export class KeepaliveEngine {
115
119
  const status: string | undefined = event.properties?.status?.type
116
120
  if (!sessionID || !status) return
117
121
 
118
- // The ping's own status transitions must not restart its idle window.
119
- if (this.store.get(sessionID)?.warming) return
122
+ // The ping and its optional revert emit status transitions after their
123
+ // requests resolve. Ignore them briefly so they cannot re-arm/reset the
124
+ // active warm window. Real user-message events still stop warming now.
125
+ const current = this.store.get(sessionID)
126
+ if (
127
+ current?.warming ||
128
+ (status !== "idle" &&
129
+ current?.lastPingAt &&
130
+ Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)
131
+ )
132
+ return
120
133
 
121
134
  if (status === "idle") {
122
135
  await this.armWindow(sessionID)
@@ -125,6 +138,7 @@ export class KeepaliveEngine {
125
138
 
126
139
  const s = this.store.ensure(sessionID)
127
140
  s.busy = true
141
+ s.busySince = Date.now()
128
142
  s.active = false
129
143
  this.store.persist()
130
144
  return
@@ -144,32 +158,52 @@ export class KeepaliveEngine {
144
158
  // Duplicate idle events (including late events from a ping) must not reset the
145
159
  // current idle stretch or extend its warm window.
146
160
  if (s.active && !s.busy) return
147
- s.busy = false
148
- await this.resolveSession(s)
161
+ // Serialize: only one armWindow may resolve metadata at a time per session.
162
+ // This prevents the session.status + session.idle double-event race from
163
+ // producing two concurrent resolveSession calls that can clobber each other.
164
+ if (s.arming) return
165
+ s.arming = true
166
+ try {
167
+ s.busy = false
168
+ s.busySince = undefined
169
+ await this.resolveSession(s)
149
170
 
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
171
+ // A new turn may have started while session metadata was being resolved.
172
+ if (s.busy || !this.enabled || !s.eligible) {
173
+ s.active = false
174
+ this.store.persist()
175
+ return
176
+ }
177
+
178
+ const now = Date.now()
179
+ if (!s.lastResponseAt) s.lastResponseAt = now
180
+ s.idleSince = now
181
+ s.windowEndsAt = now + this.config.windowMs
182
+ s.nextPingAt = now + this.config.intervalMs
183
+ s.pingsSent = 0
184
+ s.lastPing = undefined
185
+ s.active = true
153
186
  this.store.persist()
154
- return
187
+ this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
188
+ } finally {
189
+ s.arming = false
155
190
  }
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
191
  }
168
192
 
169
193
  private async tick(): Promise<void> {
170
194
  if (!this.enabled) return
171
195
  const now = Date.now()
172
196
  for (const s of this.store.all()) {
197
+ // Backstop: if busy is stuck (missed idle event + reconcile failure),
198
+ // clear it after BUSY_TIMEOUT_MS so the session can re-arm.
199
+ if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
200
+ this.logger.dbg(`busy timeout ${short(s.sessionID)} — clearing stuck flag`)
201
+ s.busy = false
202
+ s.busySince = undefined
203
+ void this.armWindow(s.sessionID)
204
+ continue
205
+ }
206
+
173
207
  if (!s.eligible || !s.active || s.busy || s.warming) continue
174
208
 
175
209
  if (now >= s.windowEndsAt) {
@@ -190,6 +224,9 @@ export class KeepaliveEngine {
190
224
  if (!s || s.warming || s.busy) return
191
225
 
192
226
  s.warming = true
227
+ // Count every request attempt. A transport/provider failure still consumed a
228
+ // scheduled ping slot and must remain visible in the footer.
229
+ s.pingsSent += 1
193
230
  this.store.persist()
194
231
  const startAt = Date.now()
195
232
 
@@ -210,9 +247,7 @@ export class KeepaliveEngine {
210
247
  output: Number(info?.tokens?.output ?? 0),
211
248
  }
212
249
  s.lastPing = record
213
- s.lastPingAt = record.at
214
- s.pingsSent += 1
215
- this.logger.info(
250
+ this.logger.dbg(
216
251
  `ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} ` +
217
252
  `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
218
253
  )
@@ -222,7 +257,14 @@ export class KeepaliveEngine {
222
257
  this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
223
258
  } finally {
224
259
  s.warming = false
225
- s.nextPingAt = Date.now() + jitter(this.config.intervalMs)
260
+ s.lastPingAt = Date.now()
261
+ // Schedule relative to the intended time so ping duration doesn't
262
+ // compound drift. Floor at now+1s to avoid immediate re-fire if the
263
+ // ping took longer than the interval.
264
+ s.nextPingAt = Math.max(
265
+ Date.now() + 1_000,
266
+ s.nextPingAt + jitter(this.config.intervalMs),
267
+ )
226
268
  this.store.persist()
227
269
  }
228
270
  }
@@ -297,8 +339,10 @@ export class KeepaliveEngine {
297
339
  session.active = false
298
340
  continue
299
341
  }
342
+ // Only re-arm sessions that have had a real turn (cache to warm) and
343
+ // are not currently occupied.
300
344
  if (!session.eligible || session.busy || session.warming) continue
301
- if (!session.lastResponseAt) session.lastResponseAt = now
345
+ if (!session.lastResponseAt) continue
302
346
  session.idleSince = now
303
347
  session.windowEndsAt = now + this.config.windowMs
304
348
  session.nextPingAt = now + this.config.intervalMs
package/lib/logger.ts CHANGED
@@ -1,21 +1,47 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+
1
4
  const PREFIX = "[cache-keepalive]"
2
5
 
3
6
  export class Logger {
4
- constructor(private readonly debug: boolean) {}
7
+ private readonly logPath: string
8
+ private readonly debug: boolean
9
+ private initialized = false
10
+
11
+ constructor(debug: boolean, logPath: string) {
12
+ this.debug = debug
13
+ this.logPath = logPath
14
+ }
5
15
 
6
16
  info(message: string, data?: unknown) {
7
- if (this.debug) console.log(`${PREFIX} ${message}`, data ?? "")
17
+ this.write("INFO", message, data)
8
18
  }
9
19
 
10
20
  warn(message: string, data?: unknown) {
11
- console.warn(`${PREFIX} ${message}`, data ?? "")
21
+ this.write("WARN", message, data)
12
22
  }
13
23
 
14
24
  error(message: string, data?: unknown) {
15
- console.error(`${PREFIX} ${message}`, data ?? "")
25
+ this.write("ERROR", message, data)
16
26
  }
17
27
 
18
28
  dbg(message: string, data?: unknown) {
19
- if (this.debug) console.log(`${PREFIX}:debug ${message}`, data ?? "")
29
+ if (this.debug) this.write("DEBUG", message, data)
30
+ }
31
+
32
+ private write(level: string, message: string, data?: unknown): void {
33
+ const ts = new Date().toISOString()
34
+ const extra =
35
+ data !== undefined ? ` ${typeof data === "string" ? data : JSON.stringify(data)}` : ""
36
+ const line = `${ts} ${level} ${PREFIX} ${message}${extra}\n`
37
+ try {
38
+ if (!this.initialized) {
39
+ mkdirSync(dirname(this.logPath), { recursive: true })
40
+ this.initialized = true
41
+ }
42
+ appendFileSync(this.logPath, line, "utf8")
43
+ } catch {
44
+ // Best-effort logging — never crash the plugin.
45
+ }
20
46
  }
21
47
  }
package/lib/paths.ts CHANGED
@@ -29,3 +29,8 @@ export function stateDirectoryPath(): string {
29
29
  export function controlFilePath(): string {
30
30
  return join(stateDirectoryPath(), "control.json")
31
31
  }
32
+
33
+ /** Append-only log file for the server plugin — never stdout, which corrupts the TUI. */
34
+ export function logFilePath(): string {
35
+ return join(stateDirectoryPath(), "server.log")
36
+ }
package/lib/state.ts CHANGED
@@ -26,14 +26,18 @@ export type SessionKeepalive = {
26
26
  windowEndsAt: number
27
27
  /** Epoch ms the next ping is due. */
28
28
  nextPingAt: number
29
- /** Pings sent during the current idle stretch. */
29
+ /** Number of scheduled ping requests attempted during this warm window. */
30
30
  pingsSent: number
31
31
  /** A real turn is currently running. */
32
32
  busy: boolean
33
+ /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
34
+ busySince?: number
33
35
  /** A keepalive ping is currently in flight. */
34
36
  warming: boolean
35
37
  /** Within the warm window and actively scheduling pings. */
36
38
  active: boolean
39
+ /** True while `armWindow` is resolving session metadata (async guard). */
40
+ arming?: boolean
37
41
  /** Epoch ms of the last ping; retained only to ignore its late events. */
38
42
  lastPingAt?: number
39
43
  lastPing?: PingRecord
@@ -38,7 +38,11 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
38
38
  const match = readdirSync(stateDirectoryPath())
39
39
  .filter((name) => name.startsWith("state-") && name.endsWith(".json"))
40
40
  .map((name) => readState(join(stateDirectoryPath(), name)))
41
- .find((snapshot) => snapshot?.sessions?.[sessionID])
41
+ .filter(
42
+ (snapshot): snapshot is PersistedState =>
43
+ snapshot?.sessions?.[sessionID] !== undefined,
44
+ )
45
+ .sort((a, b) => b.updatedAt - a.updatedAt)[0]
42
46
  setState(match)
43
47
  } catch {
44
48
  setState(undefined)
@@ -63,8 +67,11 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
63
67
 
64
68
  const interval = session.intervalMs || snapshot.intervalMs || 270_000
65
69
  const idleMs = Math.max(0, now() - (session.lastResponseAt || now()))
70
+ // `nextPingAt` is the scheduler's source of truth and includes the
71
+ // per-ping jitter. Counting from it keeps the two counters in step.
72
+ const nextPingAt = Math.max(session.nextPingAt || now(), now())
66
73
  const left = session.active
67
- ? Math.max(0, Math.floor((session.windowEndsAt - now()) / interval))
74
+ ? Math.max(0, Math.ceil((session.windowEndsAt - nextPingAt) / interval))
68
75
  : 0
69
76
  const status: Status =
70
77
  !snapshot.enabled || !session.eligible
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.0",
4
+ "version": "0.1.2",
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",