@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/README.md CHANGED
@@ -18,6 +18,7 @@ Keep LLM prompt caches warm during idle periods with invisible pings and a live
18
18
  - **Live TUI footer**: idle time, pings sent/left, last ping cache hit/miss with token counts
19
19
  - **Synthetic ping reversion**: removes `~`/`~` turns from conversation history
20
20
  - **Runtime toggle**: `/keepalive-toggle`, `/keepalive-on`, `/keepalive-off` slash commands
21
+ - **Runtime interval override**: set a per-project ping interval with presets, custom durations, or reset to the plugin default
21
22
  - **Secure mode support**: works with `OPENCODE_SERVER_PASSWORD`
22
23
  - **Configurable provider/model allowlists**
23
24
 
@@ -85,7 +86,7 @@ All options can be set via plugin config in `opencode.json` or environment varia
85
86
  The plugin registers a sidebar footer showing live keepalive status:
86
87
 
87
88
  ```
88
- keepalive armed
89
+ keepalive armed every 4m 30s
89
90
  idle 03:42 · sent 2 · left 10
90
91
  ✓ hit input 12.4k · read 11.8k · write 0
91
92
  ```
@@ -93,6 +94,7 @@ idle 03:42 · sent 2 · left 10
93
94
  Metrics:
94
95
 
95
96
  - **idle**: Time since last real assistant response
97
+ - **every**: Effective ping interval, including any runtime project override
96
98
  - **sent**: Ping attempts in current warm window (includes failures)
97
99
  - **left**: Estimated pings remaining before window expires
98
100
  - **hit/miss**: Cache hit status of last ping with token breakdown
@@ -102,6 +104,7 @@ Slash commands (available in TUI palette):
102
104
  - `/keepalive-toggle` — Toggle keepalive on/off at runtime
103
105
  - `/keepalive-on` — Enable keepalive
104
106
  - `/keepalive-off` — Disable keepalive
107
+ - `/keepalive-interval` — Set this project's ping interval at runtime (presets for 5-minute and 30-minute caches, custom durations, or reset to the plugin default); persists per project until changed
105
108
 
106
109
  ## How It Works
107
110
 
package/dist/index.js CHANGED
@@ -88,6 +88,7 @@ function numEnv(raw, fallback) {
88
88
  }
89
89
 
90
90
  // lib/control.ts
91
+ import { randomUUID } from "crypto";
91
92
  import { readFileSync } from "fs";
92
93
  import { mkdir, rename, writeFile } from "fs/promises";
93
94
  import { dirname } from "path";
@@ -118,18 +119,27 @@ function logFilePath() {
118
119
  }
119
120
 
120
121
  // lib/control.ts
122
+ var MIN_INTERVAL_MS = 6e4;
123
+ var MAX_INTERVAL_MS = 24 * 36e5;
124
+ function isValidInterval(ms) {
125
+ return typeof ms === "number" && Number.isFinite(ms) && ms >= MIN_INTERVAL_MS && ms <= MAX_INTERVAL_MS;
126
+ }
121
127
  function readControl(directory) {
122
128
  try {
123
- const value = JSON.parse(
124
- readFileSync(controlFilePath(directory), "utf8")
125
- );
126
- if (value.version !== 1 || typeof value.enabled !== "boolean") return void 0;
127
- if (typeof value.updatedAt !== "number") return void 0;
128
- return value;
129
+ const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8"));
130
+ if (value?.version !== 1 || typeof value.updatedAt !== "number") return void 0;
131
+ if (value.enabled !== void 0 && typeof value.enabled !== "boolean") return void 0;
132
+ return {
133
+ version: 1,
134
+ updatedAt: value.updatedAt,
135
+ ...typeof value.enabled === "boolean" ? { enabled: value.enabled } : {},
136
+ ...isValidInterval(value.intervalMs) ? { intervalMs: value.intervalMs } : {}
137
+ };
129
138
  } catch {
130
139
  return void 0;
131
140
  }
132
141
  }
142
+ var updateQueue = Promise.resolve();
133
143
 
134
144
  // lib/model.ts
135
145
  function isEligibleModel(config, providerID, modelID) {
@@ -147,7 +157,8 @@ var JITTER_MS = 15e3;
147
157
  var CONTROL_POLL_MS = 1e3;
148
158
  var STATUS_POLL_MS = 2e3;
149
159
  var PING_STATUS_GRACE_MS = 5e3;
150
- var BUSY_TIMEOUT_MS = 3e5;
160
+ var COLD_AFTER_INTERVALS = 2;
161
+ var MESSAGE_LOOKUP_LIMIT = 50;
151
162
  var KeepaliveEngine = class {
152
163
  constructor(client, config, store, logger, directory) {
153
164
  this.client = client;
@@ -157,8 +168,10 @@ var KeepaliveEngine = class {
157
168
  this.directory = directory;
158
169
  const control = readControl(directory);
159
170
  this.enabled = control?.enabled ?? config.enabled;
171
+ this.intervalOverride = control?.intervalMs;
160
172
  this.controlUpdatedAt = control?.updatedAt ?? 0;
161
173
  this.store.setEnabled(this.enabled);
174
+ this.store.setIntervalMs(this.intervalMs);
162
175
  }
163
176
  client;
164
177
  config;
@@ -171,6 +184,11 @@ var KeepaliveEngine = class {
171
184
  controlUpdatedAt = 0;
172
185
  reconcilingStatus = false;
173
186
  enabled;
187
+ intervalOverride;
188
+ /** Effective ping interval: the runtime override from the control file, else config. */
189
+ get intervalMs() {
190
+ return this.intervalOverride ?? this.config.intervalMs;
191
+ }
174
192
  start() {
175
193
  if (this.timer) return;
176
194
  this.timer = setInterval(() => void this.tick(), TICK_MS);
@@ -178,7 +196,7 @@ var KeepaliveEngine = class {
178
196
  this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS);
179
197
  this.store.persist();
180
198
  this.logger.info(
181
- `started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.config.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
199
+ `started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
182
200
  );
183
201
  }
184
202
  stop() {
@@ -190,9 +208,14 @@ var KeepaliveEngine = class {
190
208
  if (this.statusTimer) clearInterval(this.statusTimer);
191
209
  this.statusTimer = void 0;
192
210
  }
193
- /** True while a ping is in flight — used to block tool calls during a ping. */
194
- isWarming(sessionID) {
195
- return !!this.store.get(sessionID)?.warming;
211
+ /**
212
+ * True while a ping turn owns the session, so tool calls must be blocked. Becomes
213
+ * false as soon as a real user message joins the in-flight ping run: the rest of
214
+ * that run is real work and needs its tools.
215
+ */
216
+ shouldBlockTools(sessionID) {
217
+ const s = this.store.get(sessionID);
218
+ return !!s?.warming && !s.ping?.interrupted;
196
219
  }
197
220
  async onEvent(event) {
198
221
  const type = event?.type;
@@ -205,19 +228,31 @@ var KeepaliveEngine = class {
205
228
  }
206
229
  return;
207
230
  }
231
+ if (type === "message.part.updated") {
232
+ const part = event.properties?.part;
233
+ const sessionID = part?.sessionID ?? event.properties?.sessionID;
234
+ if (!sessionID) return;
235
+ const session = this.store.get(sessionID);
236
+ if (session?.warming) this.notePingRunPart(session, part);
237
+ return;
238
+ }
208
239
  if (type === "message.updated") {
209
240
  const info = event.properties?.info;
210
241
  const sessionID = info?.sessionID ?? event.properties?.sessionID;
211
242
  if (!sessionID) return;
212
243
  const session = this.store.get(sessionID);
213
- if (session?.warming) return;
244
+ if (session?.warming) {
245
+ if (info?.role === "user") this.notePingRunUser(session, info);
246
+ return;
247
+ }
214
248
  const eventAt = info?.time?.completed ?? info?.time?.created;
215
249
  if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
216
250
  return;
217
251
  if (info?.role === "user") {
218
252
  const s = this.store.ensure(sessionID);
253
+ if (!isNewUserMessage(s, info)) return;
254
+ noteUserMessage(s, info);
219
255
  s.busy = true;
220
- s.busySince = Date.now();
221
256
  s.active = false;
222
257
  this.store.persist();
223
258
  return;
@@ -242,7 +277,6 @@ var KeepaliveEngine = class {
242
277
  }
243
278
  const s = this.store.ensure(sessionID);
244
279
  s.busy = true;
245
- s.busySince = Date.now();
246
280
  s.active = false;
247
281
  this.store.persist();
248
282
  return;
@@ -254,6 +288,55 @@ var KeepaliveEngine = class {
254
288
  await this.armWindow(sessionID);
255
289
  }
256
290
  }
291
+ /**
292
+ * Track a new user message observed while a ping is in flight. Event order alone
293
+ * cannot tell the ping's own message from a real prompt racing it, so messages are
294
+ * held until their text part arrives (see `notePingRunPart`). Any message seen
295
+ * before the ping is sent, or a second new message, is necessarily a real turn.
296
+ */
297
+ notePingRunUser(s, info) {
298
+ const ping = s.ping;
299
+ if (!ping || typeof info?.id !== "string") return;
300
+ const created = info?.time?.created;
301
+ if (typeof created === "number" && created < ping.startedAt) return;
302
+ if (info.id === ping.messageID || ping.pending.has(info.id)) return;
303
+ if (!ping.sent || ping.messageID) {
304
+ this.interruptPing(s, info);
305
+ return;
306
+ }
307
+ ping.pending.set(info.id, info);
308
+ if (ping.pending.size > 1) this.interruptPing(s);
309
+ }
310
+ /**
311
+ * Classify a pending user message by its first text part: the ping token marks
312
+ * the ping's own message; any other text is a real turn joining the ping run.
313
+ * opencode publishes a user message's parts right after the message itself, before
314
+ * the run reaches any tool call.
315
+ */
316
+ notePingRunPart(s, part) {
317
+ const ping = s.ping;
318
+ if (!ping || part?.type !== "text" || part?.synthetic) return;
319
+ const id = part?.messageID;
320
+ if (typeof id !== "string" || !ping.pending.has(id)) return;
321
+ const info = ping.pending.get(id);
322
+ ping.pending.delete(id);
323
+ if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
324
+ ping.messageID = id;
325
+ return;
326
+ }
327
+ this.interruptPing(s, info);
328
+ }
329
+ /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
330
+ interruptPing(s, info) {
331
+ const ping = s.ping;
332
+ if (!ping) return;
333
+ if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`);
334
+ ping.interrupted = true;
335
+ if (info) noteUserMessage(s, info);
336
+ s.busy = true;
337
+ s.active = false;
338
+ this.store.persist();
339
+ }
257
340
  async armWindow(sessionID) {
258
341
  const s = this.store.ensure(sessionID);
259
342
  if (s.active && !s.busy) return;
@@ -261,7 +344,6 @@ var KeepaliveEngine = class {
261
344
  s.arming = true;
262
345
  try {
263
346
  s.busy = false;
264
- s.busySince = void 0;
265
347
  await this.resolveSession(s);
266
348
  if (s.busy || !this.enabled || !s.eligible) {
267
349
  s.active = false;
@@ -269,30 +351,37 @@ var KeepaliveEngine = class {
269
351
  return;
270
352
  }
271
353
  const now = Date.now();
272
- if (!s.lastResponseAt) s.lastResponseAt = now;
273
- s.idleSince = now;
274
- s.windowEndsAt = now + this.config.windowMs;
275
- s.nextPingAt = now + this.config.intervalMs;
276
- s.pingsSent = 0;
277
- s.lastPing = void 0;
278
- s.active = true;
354
+ if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
355
+ s.active = false;
356
+ this.store.persist();
357
+ this.logger.dbg(`not armed ${short(sessionID)} \u2014 no recent real response`);
358
+ return;
359
+ }
360
+ this.openWindow(s, now);
279
361
  this.store.persist();
280
362
  this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
281
363
  } finally {
282
364
  s.arming = false;
283
365
  }
284
366
  }
367
+ /** Start a warm window anchored on the last real response and the last cache touch. */
368
+ openWindow(s, now) {
369
+ s.idleSince = now;
370
+ s.windowEndsAt = s.lastResponseAt + this.config.windowMs;
371
+ s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs);
372
+ s.pingsSent = 0;
373
+ s.lastPing = void 0;
374
+ s.active = true;
375
+ }
376
+ /** False once the cache has gone too long without a request to still be trusted warm. */
377
+ cacheMayBeWarm(s, now) {
378
+ const touch = lastCacheTouch(s);
379
+ return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs;
380
+ }
285
381
  async tick() {
286
382
  if (!this.enabled) return;
287
383
  const now = Date.now();
288
384
  for (const s of this.store.all()) {
289
- if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
290
- this.logger.dbg(`busy timeout ${short(s.sessionID)} \u2014 clearing stuck flag`);
291
- s.busy = false;
292
- s.busySince = void 0;
293
- void this.armWindow(s.sessionID);
294
- continue;
295
- }
296
385
  if (!s.eligible || !s.active || s.busy || s.warming) continue;
297
386
  if (now >= s.windowEndsAt) {
298
387
  s.active = false;
@@ -302,22 +391,65 @@ var KeepaliveEngine = class {
302
391
  );
303
392
  continue;
304
393
  }
305
- if (now >= s.nextPingAt) void this.firePing(s.sessionID);
394
+ if (now < s.nextPingAt) continue;
395
+ if (!this.cacheMayBeWarm(s, now)) {
396
+ s.active = false;
397
+ this.store.persist();
398
+ this.logger.dbg(
399
+ `window closed ${short(s.sessionID)} \u2014 ping overdue, cache presumed cold`
400
+ );
401
+ continue;
402
+ }
403
+ void this.firePing(s.sessionID);
306
404
  }
307
405
  }
308
406
  async firePing(sessionID) {
309
407
  const s = this.store.get(sessionID);
310
- if (!s || s.warming || s.busy) return;
408
+ if (!s || s.warming || s.busy || !s.active) return;
409
+ const ping = {
410
+ startedAt: Date.now(),
411
+ sent: false,
412
+ pending: /* @__PURE__ */ new Map(),
413
+ interrupted: false
414
+ };
311
415
  s.warming = true;
312
- s.pingsSent += 1;
416
+ s.ping = ping;
313
417
  this.store.persist();
314
- const startAt = Date.now();
315
418
  try {
419
+ if (!await this.isIdleOnServer(sessionID)) {
420
+ s.busy = true;
421
+ s.active = false;
422
+ this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 session busy`);
423
+ return;
424
+ }
425
+ const request = s.request ?? await this.lookupRequest(s);
426
+ if (!request) {
427
+ s.active = false;
428
+ this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 last real request unknown`);
429
+ return;
430
+ }
431
+ if (ping.interrupted || s.busy || !s.active || !this.enabled) return;
432
+ ping.sent = true;
433
+ s.pingsSent += 1;
434
+ this.store.persist();
316
435
  const res = await this.client.session.prompt({
317
436
  path: { id: sessionID },
318
- body: { parts: [{ type: "text", text: this.config.pingToken }] }
437
+ body: {
438
+ parts: [{ type: "text", text: this.config.pingToken }],
439
+ ...request.agent ? { agent: request.agent } : {},
440
+ model: { providerID: request.providerID, modelID: request.modelID },
441
+ ...request.variant ? { variant: request.variant } : {}
442
+ }
319
443
  });
320
444
  const info = res?.data?.info ?? res?.info;
445
+ if (ping.interrupted) {
446
+ const completed = info?.time?.completed;
447
+ if (typeof completed === "number") s.lastResponseAt = completed;
448
+ this.logger.dbg(
449
+ `ping ${short(sessionID)} absorbed a real turn \u2014 kept, not reverted`
450
+ );
451
+ return;
452
+ }
321
453
  const cache = info?.tokens?.cache ?? {};
322
454
  const record = {
323
455
  at: Date.now(),
@@ -331,40 +463,94 @@ var KeepaliveEngine = class {
331
463
  this.logger.dbg(
332
464
  `ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`
333
465
  );
334
- if (this.config.revertPing) await this.revertPing(sessionID, startAt);
466
+ if (this.config.revertPing) await this.revertPing(sessionID, ping);
335
467
  } catch (err) {
336
468
  this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
337
469
  } finally {
338
470
  s.warming = false;
339
- s.lastPingAt = Date.now();
340
- s.nextPingAt = Math.max(
341
- Date.now() + 1e3,
342
- s.nextPingAt + jitter(this.config.intervalMs)
343
- );
471
+ s.ping = void 0;
472
+ const now = Date.now();
473
+ if (ping.sent) {
474
+ s.lastPingAt = now;
475
+ const next = s.nextPingAt + jitter(this.intervalMs);
476
+ s.nextPingAt = next > now ? next : now + jitter(this.intervalMs);
477
+ }
344
478
  this.store.persist();
479
+ if (ping.sent && ping.interrupted) {
480
+ s.busy = false;
481
+ s.active = false;
482
+ void this.armWindow(sessionID);
483
+ }
345
484
  }
346
485
  }
347
- /** Remove the `~` user + `~` assistant turn so it never enters real context. */
348
- async revertPing(sessionID, startAt) {
349
- let messages;
486
+ /** True only when the server positively reports the session idle. */
487
+ async isIdleOnServer(sessionID) {
350
488
  try {
351
- const res = await this.client.session.messages({ path: { id: sessionID } });
352
- messages = res?.data ?? res ?? [];
489
+ const res = await this.client.session.status();
490
+ const statuses = res?.data ?? res ?? {};
491
+ return (statuses?.[sessionID]?.type ?? "idle") === "idle";
353
492
  } catch (err) {
354
- this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err));
355
- return;
493
+ this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err));
494
+ return false;
495
+ }
496
+ }
497
+ /** Recover the last real turn's request settings when no user event was observed. */
498
+ async lookupRequest(s) {
499
+ const messages = await this.recentMessages(s.sessionID);
500
+ if (!messages) return void 0;
501
+ for (let i = messages.length - 1; i >= 0; i--) {
502
+ const m = messages[i];
503
+ if (m?.info?.role !== "user" || this.isPingMessage(m)) continue;
504
+ const request = requestFrom(m.info);
505
+ if (request) {
506
+ s.request = request;
507
+ return request;
508
+ }
509
+ }
510
+ return void 0;
511
+ }
512
+ async recentMessages(sessionID) {
513
+ try {
514
+ const res = await this.client.session.messages({
515
+ path: { id: sessionID },
516
+ query: { limit: MESSAGE_LOOKUP_LIMIT }
517
+ });
518
+ const messages = res?.data ?? res;
519
+ return Array.isArray(messages) ? messages : void 0;
520
+ } catch (err) {
521
+ this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err));
522
+ return void 0;
356
523
  }
357
- let target;
358
- for (const m of messages) {
524
+ }
525
+ isPingMessage(m) {
526
+ const text = (m?.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
527
+ return text === this.config.pingToken;
528
+ }
529
+ /**
530
+ * Remove the `~` user + `~` assistant turn so it never enters real context.
531
+ * Revert drops the target and everything after it (and rolls back file
532
+ * snapshots), so it is skipped unless only the ping's own replies follow.
533
+ */
534
+ async revertPing(sessionID, ping) {
535
+ const messages = await this.recentMessages(sessionID);
536
+ if (!messages) return;
537
+ let index = -1;
538
+ for (let i = 0; i < messages.length; i++) {
539
+ const m = messages[i];
359
540
  if (m?.info?.role !== "user") continue;
360
- if (Number(m.info?.time?.created ?? 0) < startAt - 2e3) continue;
361
- const text = (m.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
362
- if (text === this.config.pingToken) target = m.info.id;
541
+ if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
542
+ index = i;
363
543
  }
364
- if (!target) {
544
+ if (index < 0) {
365
545
  this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
366
546
  return;
367
547
  }
548
+ const target = messages[index].info.id;
549
+ const foreign = messages.slice(index + 1).some((m) => m?.info?.role !== "assistant" || m?.info?.parentID !== target);
550
+ if (foreign) {
551
+ this.logger.warn(`ping ${short(sessionID)} not reverted \u2014 other messages followed it`);
552
+ return;
553
+ }
368
554
  try {
369
555
  await this.client.session.revert({
370
556
  path: { id: sessionID },
@@ -374,6 +560,9 @@ var KeepaliveEngine = class {
374
560
  this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err));
375
561
  }
376
562
  }
563
+ isRecentPing(m, ping) {
564
+ return Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2e3 && this.isPingMessage(m);
565
+ }
377
566
  async resolveSession(s) {
378
567
  try {
379
568
  const res = await this.client.session.get({ path: { id: s.sessionID } });
@@ -394,26 +583,37 @@ var KeepaliveEngine = class {
394
583
  const control = readControl(this.directory);
395
584
  if (!control || control.updatedAt <= this.controlUpdatedAt) return;
396
585
  this.controlUpdatedAt = control.updatedAt;
397
- if (control.enabled === this.enabled) return;
398
- this.enabled = control.enabled;
399
- this.store.setEnabled(control.enabled);
400
586
  const now = Date.now();
401
- for (const session of this.store.all()) {
402
- if (!control.enabled) {
403
- session.active = false;
404
- continue;
587
+ let changed = false;
588
+ if (control.intervalMs !== this.intervalOverride) {
589
+ this.intervalOverride = control.intervalMs;
590
+ this.store.setIntervalMs(this.intervalMs);
591
+ for (const session of this.store.all()) {
592
+ if (session.active && !session.warming)
593
+ session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs);
405
594
  }
406
- if (!session.eligible || session.busy || session.warming) continue;
407
- if (!session.lastResponseAt) continue;
408
- session.idleSince = now;
409
- session.windowEndsAt = now + this.config.windowMs;
410
- session.nextPingAt = now + this.config.intervalMs;
411
- session.pingsSent = 0;
412
- session.lastPing = void 0;
413
- session.active = true;
595
+ this.logger.info(
596
+ `runtime interval ${Math.round(this.intervalMs / 1e3)}s` + (control.intervalMs === void 0 ? " (config)" : "")
597
+ );
598
+ changed = true;
414
599
  }
415
- this.store.persist();
416
- this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`);
600
+ const enabled = control.enabled ?? this.config.enabled;
601
+ if (enabled !== this.enabled) {
602
+ this.enabled = enabled;
603
+ this.store.setEnabled(enabled);
604
+ for (const session of this.store.all()) {
605
+ if (!enabled) {
606
+ session.active = false;
607
+ continue;
608
+ }
609
+ if (!session.eligible || session.busy || session.warming) continue;
610
+ if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue;
611
+ this.openWindow(session, now);
612
+ }
613
+ this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`);
614
+ changed = true;
615
+ }
616
+ if (changed) this.store.persist();
417
617
  }
418
618
  /** Recover when a session.status idle event is missed by reconciling with the API. */
419
619
  async reconcileStatus() {
@@ -435,6 +635,34 @@ var KeepaliveEngine = class {
435
635
  }
436
636
  }
437
637
  };
638
+ function lastCacheTouch(s) {
639
+ return Math.max(s.lastResponseAt, s.lastPingAt ?? 0);
640
+ }
641
+ function isNewUserMessage(s, info) {
642
+ if (typeof info?.id === "string" && info.id === s.lastUserID) return false;
643
+ const created = info?.time?.created;
644
+ return typeof created !== "number" || created >= (s.lastUserAt ?? 0);
645
+ }
646
+ function noteUserMessage(s, info) {
647
+ if (typeof info?.id === "string") s.lastUserID = info.id;
648
+ const created = info?.time?.created;
649
+ if (typeof created === "number") s.lastUserAt = created;
650
+ const request = requestFrom(info);
651
+ if (request) s.request = request;
652
+ }
653
+ function requestFrom(info) {
654
+ const model = info?.model;
655
+ const providerID = model?.providerID;
656
+ const modelID = model?.modelID ?? model?.id;
657
+ if (typeof providerID !== "string" || typeof modelID !== "string") return void 0;
658
+ const variant = model?.variant ?? info?.variant;
659
+ return {
660
+ agent: typeof info?.agent === "string" ? info.agent : void 0,
661
+ providerID,
662
+ modelID,
663
+ variant: typeof variant === "string" && variant !== "default" ? variant : void 0
664
+ };
665
+ }
438
666
  function short(sessionID) {
439
667
  return sessionID.slice(-6);
440
668
  }
@@ -493,15 +721,21 @@ var KeepaliveStore = class {
493
721
  this.config = config;
494
722
  this.directory = directory;
495
723
  this.enabled = config.enabled;
724
+ this.intervalMs = config.intervalMs;
496
725
  }
497
726
  config;
498
727
  directory;
499
728
  sessions = /* @__PURE__ */ new Map();
500
729
  writeQueue = Promise.resolve();
501
730
  enabled;
731
+ intervalMs;
502
732
  setEnabled(enabled) {
503
733
  this.enabled = enabled;
504
734
  }
735
+ /** Effective ping interval (runtime override or config) reported to the TUI. */
736
+ setIntervalMs(intervalMs) {
737
+ this.intervalMs = intervalMs;
738
+ }
505
739
  get(sessionID) {
506
740
  return this.sessions.get(sessionID);
507
741
  }
@@ -529,13 +763,16 @@ var KeepaliveStore = class {
529
763
  all() {
530
764
  return [...this.sessions.values()];
531
765
  }
532
- /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
766
+ /**
767
+ * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
768
+ * returned promise resolves once this snapshot and every earlier one are written.
769
+ */
533
770
  persist() {
534
771
  const snapshot = {
535
772
  version: 1,
536
773
  updatedAt: Date.now(),
537
774
  enabled: this.enabled,
538
- intervalMs: this.config.intervalMs,
775
+ intervalMs: this.intervalMs,
539
776
  windowMs: this.config.windowMs,
540
777
  sessions: Object.fromEntries(
541
778
  this.all().map((s) => [
@@ -547,7 +784,7 @@ var KeepaliveStore = class {
547
784
  idleSince: s.idleSince,
548
785
  windowEndsAt: s.windowEndsAt,
549
786
  nextPingAt: s.nextPingAt,
550
- intervalMs: this.config.intervalMs,
787
+ intervalMs: this.intervalMs,
551
788
  pingsSent: s.pingsSent,
552
789
  busy: s.busy,
553
790
  warming: s.warming,
@@ -559,6 +796,7 @@ var KeepaliveStore = class {
559
796
  };
560
797
  const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
561
798
  this.writeQueue = this.writeQueue.then(write, write);
799
+ return this.writeQueue;
562
800
  }
563
801
  };
564
802
  async function atomicWrite(path, contents) {
@@ -596,7 +834,7 @@ var server = async (ctx, options) => {
596
834
  const store = new KeepaliveStore(config, ctx.directory);
597
835
  const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory);
598
836
  engine.start();
599
- const version = true ? "0.1.4" : "dev";
837
+ const version = true ? "0.1.6" : "dev";
600
838
  logger.info(`v${version} ready`);
601
839
  const instruction = keepaliveInstruction(config.pingToken);
602
840
  return {
@@ -614,7 +852,7 @@ var server = async (ctx, options) => {
614
852
  // Backstop: if the model ignores the instruction and tries a tool during a
615
853
  // ping, block it — the ping turn is going to be reverted anyway.
616
854
  "tool.execute.before": async (input) => {
617
- if (engine.isWarming(input.sessionID)) {
855
+ if (engine.shouldBlockTools(input.sessionID)) {
618
856
  throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
619
857
  }
620
858
  },