@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.5

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/keepalive.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { KeepaliveConfig } from "./config"
2
2
  import { readControl } from "./control"
3
3
  import { isEligibleModel } from "./model"
4
- import type { KeepaliveStore, SessionKeepalive } from "./state"
4
+ import type { InflightPing, KeepaliveStore, PingRequest, SessionKeepalive } from "./state"
5
5
  import type { Logger } from "./logger"
6
6
 
7
7
  const TICK_MS = 15_000
@@ -9,19 +9,32 @@ const JITTER_MS = 15_000
9
9
  const CONTROL_POLL_MS = 1_000
10
10
  const STATUS_POLL_MS = 2_000
11
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
12
+ /**
13
+ * A cache last touched more than this many intervals ago is presumed cold. Pinging
14
+ * it would pay a full cache write for a session nobody is using, so the window
15
+ * stays closed until the next real turn re-arms it.
16
+ */
17
+ const COLD_AFTER_INTERVALS = 2
18
+ /** Recent messages scanned for ping reverts and request-setting recovery. */
19
+ const MESSAGE_LOOKUP_LIMIT = 50
14
20
 
15
21
  /**
16
22
  * The keepalive engine.
17
23
  *
18
24
  * Lifecycle per session:
19
- * 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.
25
+ * 1. A real turn finishes -> `session.status` idle -> arm a warm window anchored on
26
+ * the real response and schedule pings.
20
27
  * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
21
- * 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses
22
- * the session's model/auth), reads `usage` to confirm a cache hit, then optionally
23
- * reverts the `~`/`~` turn so it does not pollute the real conversation.
24
- * 4. A new real user turn or the window closing stops warming.
28
+ * 3. `firePing` confirms with the server that the session is idle, sends a `~`
29
+ * prompt with the last real turn's agent/model/variant (exact cached prefix),
30
+ * reads `usage` to confirm a cache hit, then optionally reverts the `~`/`~` turn.
31
+ * 4. A new real user turn, the window closing, or the cache presumed cold stops warming.
32
+ *
33
+ * Threading: all state is mutated on the single plugin event loop. opencode invokes
34
+ * the `event` hook without awaiting it, so async handlers interleave; per-session
35
+ * `arming` / `warming` flags serialize the async sections.
36
+ *
37
+ * Errors: network/API failures are logged and never thrown to opencode.
25
38
  */
26
39
  export class KeepaliveEngine {
27
40
  private timer: ReturnType<typeof setInterval> | undefined
@@ -67,9 +80,14 @@ export class KeepaliveEngine {
67
80
  this.statusTimer = undefined
68
81
  }
69
82
 
70
- /** True while a ping is in flight — used to block tool calls during a ping. */
71
- isWarming(sessionID: string): boolean {
72
- return !!this.store.get(sessionID)?.warming
83
+ /**
84
+ * True while a ping turn owns the session, so tool calls must be blocked. Becomes
85
+ * false as soon as a real user message joins the in-flight ping run: the rest of
86
+ * that run is real work and needs its tools.
87
+ */
88
+ shouldBlockTools(sessionID: string): boolean {
89
+ const s = this.store.get(sessionID)
90
+ return !!s?.warming && !s.ping?.interrupted
73
91
  }
74
92
 
75
93
  async onEvent(event: any): Promise<void> {
@@ -85,13 +103,26 @@ export class KeepaliveEngine {
85
103
  return
86
104
  }
87
105
 
106
+ if (type === "message.part.updated") {
107
+ const part = event.properties?.part
108
+ const sessionID: string | undefined = part?.sessionID ?? event.properties?.sessionID
109
+ if (!sessionID) return
110
+ const session = this.store.get(sessionID)
111
+ if (session?.warming) this.notePingRunPart(session, part)
112
+ return
113
+ }
114
+
88
115
  if (type === "message.updated") {
89
116
  const info = event.properties?.info
90
117
  const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
91
118
  if (!sessionID) return
92
119
  const session = this.store.get(sessionID)
93
- // Ignore everything that happens inside our own ping turn.
94
- if (session?.warming) return
120
+ // Everything inside our own ping turn is ignored — except a real user
121
+ // message, which opencode joins to the running ping loop.
122
+ if (session?.warming) {
123
+ if (info?.role === "user") this.notePingRunUser(session, info)
124
+ return
125
+ }
95
126
  const eventAt = info?.time?.completed ?? info?.time?.created
96
127
  // Events are asynchronous: a completed ping may still emit message updates
97
128
  // after `warming` clears. Their timestamps predate the recorded ping.
@@ -100,8 +131,11 @@ export class KeepaliveEngine {
100
131
 
101
132
  if (info?.role === "user") {
102
133
  const s = this.store.ensure(sessionID)
134
+ // opencode re-emits older user messages (e.g. summary updates after a
135
+ // turn); only a newly created message starts a turn.
136
+ if (!isNewUserMessage(s, info)) return
137
+ noteUserMessage(s, info)
103
138
  s.busy = true
104
- s.busySince = Date.now()
105
139
  s.active = false
106
140
  this.store.persist()
107
141
  return
@@ -139,7 +173,6 @@ export class KeepaliveEngine {
139
173
 
140
174
  const s = this.store.ensure(sessionID)
141
175
  s.busy = true
142
- s.busySince = Date.now()
143
176
  s.active = false
144
177
  this.store.persist()
145
178
  return
@@ -154,6 +187,58 @@ export class KeepaliveEngine {
154
187
  }
155
188
  }
156
189
 
190
+ /**
191
+ * Track a new user message observed while a ping is in flight. Event order alone
192
+ * cannot tell the ping's own message from a real prompt racing it, so messages are
193
+ * held until their text part arrives (see `notePingRunPart`). Any message seen
194
+ * before the ping is sent, or a second new message, is necessarily a real turn.
195
+ */
196
+ private notePingRunUser(s: SessionKeepalive, info: any): void {
197
+ const ping = s.ping
198
+ if (!ping || typeof info?.id !== "string") return
199
+ const created = info?.time?.created
200
+ if (typeof created === "number" && created < ping.startedAt) return // re-emitted old message
201
+ if (info.id === ping.messageID || ping.pending.has(info.id)) return
202
+ if (!ping.sent || ping.messageID) {
203
+ this.interruptPing(s, info)
204
+ return
205
+ }
206
+ ping.pending.set(info.id, info)
207
+ if (ping.pending.size > 1) this.interruptPing(s)
208
+ }
209
+
210
+ /**
211
+ * Classify a pending user message by its first text part: the ping token marks
212
+ * the ping's own message; any other text is a real turn joining the ping run.
213
+ * opencode publishes a user message's parts right after the message itself, before
214
+ * the run reaches any tool call.
215
+ */
216
+ private notePingRunPart(s: SessionKeepalive, part: any): void {
217
+ const ping = s.ping
218
+ if (!ping || part?.type !== "text" || part?.synthetic) return
219
+ const id = part?.messageID
220
+ if (typeof id !== "string" || !ping.pending.has(id)) return
221
+ const info = ping.pending.get(id)
222
+ ping.pending.delete(id)
223
+ if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
224
+ ping.messageID = id
225
+ return
226
+ }
227
+ this.interruptPing(s, info)
228
+ }
229
+
230
+ /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
231
+ private interruptPing(s: SessionKeepalive, info?: unknown): void {
232
+ const ping = s.ping
233
+ if (!ping) return
234
+ if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`)
235
+ ping.interrupted = true
236
+ if (info) noteUserMessage(s, info)
237
+ s.busy = true
238
+ s.active = false
239
+ this.store.persist()
240
+ }
241
+
157
242
  private async armWindow(sessionID: string): Promise<void> {
158
243
  const s = this.store.ensure(sessionID)
159
244
  // Duplicate idle events (including late events from a ping) must not reset the
@@ -166,7 +251,6 @@ export class KeepaliveEngine {
166
251
  s.arming = true
167
252
  try {
168
253
  s.busy = false
169
- s.busySince = undefined
170
254
  await this.resolveSession(s)
171
255
 
172
256
  // A new turn may have started while session metadata was being resolved.
@@ -176,14 +260,17 @@ export class KeepaliveEngine {
176
260
  return
177
261
  }
178
262
 
263
+ // Only a recent real response leaves a cache worth warming. Idle events can
264
+ // also fire for sessions untouched for hours (restarts, re-emitted messages).
179
265
  const now = Date.now()
180
- if (!s.lastResponseAt) s.lastResponseAt = now
181
- s.idleSince = now
182
- s.windowEndsAt = now + this.config.windowMs
183
- s.nextPingAt = now + this.config.intervalMs
184
- s.pingsSent = 0
185
- s.lastPing = undefined
186
- s.active = true
266
+ if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
267
+ s.active = false
268
+ this.store.persist()
269
+ this.logger.dbg(`not armed ${short(sessionID)} — no recent real response`)
270
+ return
271
+ }
272
+
273
+ this.openWindow(s, now)
187
274
  this.store.persist()
188
275
  this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
189
276
  } finally {
@@ -191,20 +278,26 @@ export class KeepaliveEngine {
191
278
  }
192
279
  }
193
280
 
281
+ /** Start a warm window anchored on the last real response and the last cache touch. */
282
+ private openWindow(s: SessionKeepalive, now: number): void {
283
+ s.idleSince = now
284
+ s.windowEndsAt = s.lastResponseAt + this.config.windowMs
285
+ s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.config.intervalMs)
286
+ s.pingsSent = 0
287
+ s.lastPing = undefined
288
+ s.active = true
289
+ }
290
+
291
+ /** False once the cache has gone too long without a request to still be trusted warm. */
292
+ private cacheMayBeWarm(s: SessionKeepalive, now: number): boolean {
293
+ const touch = lastCacheTouch(s)
294
+ return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.config.intervalMs
295
+ }
296
+
194
297
  private async tick(): Promise<void> {
195
298
  if (!this.enabled) return
196
299
  const now = Date.now()
197
300
  for (const s of this.store.all()) {
198
- // Backstop: if busy is stuck (missed idle event + reconcile failure),
199
- // clear it after BUSY_TIMEOUT_MS so the session can re-arm.
200
- if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
201
- this.logger.dbg(`busy timeout ${short(s.sessionID)} — clearing stuck flag`)
202
- s.busy = false
203
- s.busySince = undefined
204
- void this.armWindow(s.sessionID)
205
- continue
206
- }
207
-
208
301
  if (!s.eligible || !s.active || s.busy || s.warming) continue
209
302
 
210
303
  if (now >= s.windowEndsAt) {
@@ -216,28 +309,85 @@ export class KeepaliveEngine {
216
309
  continue
217
310
  }
218
311
 
219
- if (now >= s.nextPingAt) void this.firePing(s.sessionID)
312
+ if (now < s.nextPingAt) continue
313
+
314
+ // Far overdue (host sleep, stalled event loop): the cache is presumed cold.
315
+ // Stop rather than pay a cold write or fire a catch-up burst.
316
+ if (!this.cacheMayBeWarm(s, now)) {
317
+ s.active = false
318
+ this.store.persist()
319
+ this.logger.dbg(
320
+ `window closed ${short(s.sessionID)} — ping overdue, cache presumed cold`,
321
+ )
322
+ continue
323
+ }
324
+
325
+ void this.firePing(s.sessionID)
220
326
  }
221
327
  }
222
328
 
223
329
  private async firePing(sessionID: string): Promise<void> {
224
330
  const s = this.store.get(sessionID)
225
- if (!s || s.warming || s.busy) return
331
+ if (!s || s.warming || s.busy || !s.active) return
226
332
 
333
+ const ping: InflightPing = {
334
+ startedAt: Date.now(),
335
+ sent: false,
336
+ pending: new Map(),
337
+ interrupted: false,
338
+ }
227
339
  s.warming = true
228
- // Count every request attempt. A transport/provider failure still consumed a
229
- // scheduled ping slot and must remain visible in the footer.
230
- s.pingsSent += 1
340
+ s.ping = ping
231
341
  this.store.persist()
232
- const startAt = Date.now()
233
342
 
234
343
  try {
344
+ // opencode joins a prompt to a running turn instead of rejecting it, so a
345
+ // missed busy signal would inject the ping into real work. Ask the server.
346
+ if (!(await this.isIdleOnServer(sessionID))) {
347
+ s.busy = true
348
+ s.active = false
349
+ this.logger.dbg(`ping ${short(sessionID)} skipped — session busy`)
350
+ return
351
+ }
352
+
353
+ const request = s.request ?? (await this.lookupRequest(s))
354
+ if (!request) {
355
+ s.active = false
356
+ this.logger.dbg(`ping ${short(sessionID)} skipped — last real request unknown`)
357
+ return
358
+ }
359
+
360
+ // A real turn may have started (or keepalive been disabled) while checking.
361
+ if (ping.interrupted || s.busy || !s.active || !this.enabled) return
362
+
363
+ ping.sent = true
364
+ // Count every request attempt. A transport/provider failure still consumed a
365
+ // scheduled ping slot and must remain visible in the footer.
366
+ s.pingsSent += 1
367
+ this.store.persist()
368
+
235
369
  const res = await this.client.session.prompt({
236
370
  path: { id: sessionID },
237
- body: { parts: [{ type: "text", text: this.config.pingToken }] },
371
+ body: {
372
+ parts: [{ type: "text", text: this.config.pingToken }],
373
+ ...(request.agent ? { agent: request.agent } : {}),
374
+ model: { providerID: request.providerID, modelID: request.modelID },
375
+ ...(request.variant ? { variant: request.variant } : {}),
376
+ },
238
377
  })
239
-
240
378
  const info = res?.data?.info ?? res?.info
379
+
380
+ if (ping.interrupted) {
381
+ // A real turn ran inside this request: its reply is real work. Keep it,
382
+ // do not score it as a ping, and re-arm from its completion.
383
+ const completed = info?.time?.completed
384
+ if (typeof completed === "number") s.lastResponseAt = completed
385
+ this.logger.dbg(
386
+ `ping ${short(sessionID)} absorbed a real turn — kept, not reverted`,
387
+ )
388
+ return
389
+ }
390
+
241
391
  const cache = info?.tokens?.cache ?? {}
242
392
  const record = {
243
393
  at: Date.now(),
@@ -253,51 +403,112 @@ export class KeepaliveEngine {
253
403
  `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
254
404
  )
255
405
 
256
- if (this.config.revertPing) await this.revertPing(sessionID, startAt)
406
+ if (this.config.revertPing) await this.revertPing(sessionID, ping)
257
407
  } catch (err) {
258
408
  this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
259
409
  } finally {
260
410
  s.warming = false
261
- s.lastPingAt = Date.now()
262
- // Schedule relative to the intended time so ping duration doesn't
263
- // compound drift. Floor at now+1s to avoid immediate re-fire if the
264
- // ping took longer than the interval.
265
- s.nextPingAt = Math.max(
266
- Date.now() + 1_000,
267
- s.nextPingAt + jitter(this.config.intervalMs),
268
- )
411
+ s.ping = undefined
412
+ const now = Date.now()
413
+ if (ping.sent) {
414
+ s.lastPingAt = now
415
+ // Schedule relative to the intended time so ping duration doesn't
416
+ // compound drift, but never into the past: that would fire catch-up
417
+ // pings on consecutive ticks.
418
+ const next = s.nextPingAt + jitter(this.config.intervalMs)
419
+ s.nextPingAt = next > now ? next : now + jitter(this.config.intervalMs)
420
+ }
269
421
  this.store.persist()
422
+ if (ping.sent && ping.interrupted) {
423
+ // The joined real turn has finished with this request; start its window.
424
+ s.busy = false
425
+ s.active = false
426
+ void this.armWindow(sessionID)
427
+ }
270
428
  }
271
429
  }
272
430
 
273
- /** Remove the `~` user + `~` assistant turn so it never enters real context. */
274
- private async revertPing(sessionID: string, startAt: number): Promise<void> {
275
- let messages: any[]
431
+ /** True only when the server positively reports the session idle. */
432
+ private async isIdleOnServer(sessionID: string): Promise<boolean> {
276
433
  try {
277
- const res = await this.client.session.messages({ path: { id: sessionID } })
278
- messages = res?.data ?? res ?? []
434
+ const res = await this.client.session.status()
435
+ const statuses = res?.data ?? res ?? {}
436
+ return (statuses?.[sessionID]?.type ?? "idle") === "idle"
279
437
  } catch (err) {
280
- this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))
281
- return
438
+ this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err))
439
+ return false
282
440
  }
441
+ }
283
442
 
284
- let target: string | undefined
285
- for (const m of messages) {
286
- if (m?.info?.role !== "user") continue
287
- if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue
288
- const text = (m.parts ?? [])
289
- .filter((p: any) => p?.type === "text")
290
- .map((p: any) => p?.text ?? "")
291
- .join("")
292
- .trim()
293
- if (text === this.config.pingToken) target = m.info.id
443
+ /** Recover the last real turn's request settings when no user event was observed. */
444
+ private async lookupRequest(s: SessionKeepalive): Promise<PingRequest | undefined> {
445
+ const messages = await this.recentMessages(s.sessionID)
446
+ if (!messages) return undefined
447
+ for (let i = messages.length - 1; i >= 0; i--) {
448
+ const m = messages[i]
449
+ if (m?.info?.role !== "user" || this.isPingMessage(m)) continue
450
+ const request = requestFrom(m.info)
451
+ if (request) {
452
+ s.request = request
453
+ return request
454
+ }
294
455
  }
456
+ return undefined
457
+ }
295
458
 
296
- if (!target) {
459
+ private async recentMessages(sessionID: string): Promise<any[] | undefined> {
460
+ try {
461
+ const res = await this.client.session.messages({
462
+ path: { id: sessionID },
463
+ query: { limit: MESSAGE_LOOKUP_LIMIT },
464
+ })
465
+ const messages = res?.data ?? res
466
+ return Array.isArray(messages) ? messages : undefined
467
+ } catch (err) {
468
+ this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err))
469
+ return undefined
470
+ }
471
+ }
472
+
473
+ private isPingMessage(m: any): boolean {
474
+ const text = (m?.parts ?? [])
475
+ .filter((p: any) => p?.type === "text")
476
+ .map((p: any) => p?.text ?? "")
477
+ .join("")
478
+ .trim()
479
+ return text === this.config.pingToken
480
+ }
481
+
482
+ /**
483
+ * Remove the `~` user + `~` assistant turn so it never enters real context.
484
+ * Revert drops the target and everything after it (and rolls back file
485
+ * snapshots), so it is skipped unless only the ping's own replies follow.
486
+ */
487
+ private async revertPing(sessionID: string, ping: InflightPing): Promise<void> {
488
+ const messages = await this.recentMessages(sessionID)
489
+ if (!messages) return
490
+
491
+ let index = -1
492
+ for (let i = 0; i < messages.length; i++) {
493
+ const m = messages[i]
494
+ if (m?.info?.role !== "user") continue
495
+ if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
496
+ index = i
497
+ }
498
+ if (index < 0) {
297
499
  this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
298
500
  return
299
501
  }
300
502
 
503
+ const target: string = messages[index].info.id
504
+ const foreign = messages
505
+ .slice(index + 1)
506
+ .some((m: any) => m?.info?.role !== "assistant" || m?.info?.parentID !== target)
507
+ if (foreign) {
508
+ this.logger.warn(`ping ${short(sessionID)} not reverted — other messages followed it`)
509
+ return
510
+ }
511
+
301
512
  try {
302
513
  await this.client.session.revert({
303
514
  path: { id: sessionID },
@@ -309,6 +520,12 @@ export class KeepaliveEngine {
309
520
  }
310
521
  }
311
522
 
523
+ private isRecentPing(m: any, ping: InflightPing): boolean {
524
+ return (
525
+ Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2_000 && this.isPingMessage(m)
526
+ )
527
+ }
528
+
312
529
  private async resolveSession(s: SessionKeepalive): Promise<void> {
313
530
  try {
314
531
  const res = await this.client.session.get({ path: { id: s.sessionID } })
@@ -340,16 +557,11 @@ export class KeepaliveEngine {
340
557
  session.active = false
341
558
  continue
342
559
  }
343
- // Only re-arm sessions that have had a real turn (cache to warm) and
344
- // are not currently occupied.
560
+ // Only re-arm sessions with a real response whose cache may still be warm,
561
+ // and that are not currently occupied.
345
562
  if (!session.eligible || session.busy || session.warming) continue
346
- if (!session.lastResponseAt) continue
347
- session.idleSince = now
348
- session.windowEndsAt = now + this.config.windowMs
349
- session.nextPingAt = now + this.config.intervalMs
350
- session.pingsSent = 0
351
- session.lastPing = undefined
352
- session.active = true
563
+ if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
564
+ this.openWindow(session, now)
353
565
  }
354
566
  this.store.persist()
355
567
  this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
@@ -377,6 +589,40 @@ export class KeepaliveEngine {
377
589
  }
378
590
  }
379
591
 
592
+ /** Last time the session's prompt cache was known to be used (real response or ping). */
593
+ function lastCacheTouch(s: SessionKeepalive): number {
594
+ return Math.max(s.lastResponseAt, s.lastPingAt ?? 0)
595
+ }
596
+
597
+ function isNewUserMessage(s: SessionKeepalive, info: any): boolean {
598
+ if (typeof info?.id === "string" && info.id === s.lastUserID) return false
599
+ const created = info?.time?.created
600
+ return typeof created !== "number" || created >= (s.lastUserAt ?? 0)
601
+ }
602
+
603
+ function noteUserMessage(s: SessionKeepalive, info: any): void {
604
+ if (typeof info?.id === "string") s.lastUserID = info.id
605
+ const created = info?.time?.created
606
+ if (typeof created === "number") s.lastUserAt = created
607
+ const request = requestFrom(info)
608
+ if (request) s.request = request
609
+ }
610
+
611
+ /** Extract replayable request settings from a user message's info. */
612
+ function requestFrom(info: any): PingRequest | undefined {
613
+ const model = info?.model
614
+ const providerID = model?.providerID
615
+ const modelID = model?.modelID ?? model?.id
616
+ if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
617
+ const variant = model?.variant ?? info?.variant
618
+ return {
619
+ agent: typeof info?.agent === "string" ? info.agent : undefined,
620
+ providerID,
621
+ modelID,
622
+ variant: typeof variant === "string" && variant !== "default" ? variant : undefined,
623
+ }
624
+ }
625
+
380
626
  function short(sessionID: string): string {
381
627
  return sessionID.slice(-6)
382
628
  }
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
  }
@@ -115,8 +154,11 @@ export class KeepaliveStore {
115
154
  return [...this.sessions.values()]
116
155
  }
117
156
 
118
- /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
119
- persist(): void {
157
+ /**
158
+ * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
159
+ * returned promise resolves once this snapshot and every earlier one are written.
160
+ */
161
+ persist(): Promise<void> {
120
162
  const snapshot: PersistedState = {
121
163
  version: 1,
122
164
  updatedAt: Date.now(),
@@ -146,6 +188,7 @@ export class KeepaliveStore {
146
188
 
147
189
  const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
148
190
  this.writeQueue = this.writeQueue.then(write, write)
191
+ return this.writeQueue
149
192
  }
150
193
  }
151
194
 
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.5",
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": [