@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/dist/index.js CHANGED
@@ -147,7 +147,8 @@ var JITTER_MS = 15e3;
147
147
  var CONTROL_POLL_MS = 1e3;
148
148
  var STATUS_POLL_MS = 2e3;
149
149
  var PING_STATUS_GRACE_MS = 5e3;
150
- var BUSY_TIMEOUT_MS = 3e5;
150
+ var COLD_AFTER_INTERVALS = 2;
151
+ var MESSAGE_LOOKUP_LIMIT = 50;
151
152
  var KeepaliveEngine = class {
152
153
  constructor(client, config, store, logger, directory) {
153
154
  this.client = client;
@@ -190,9 +191,14 @@ var KeepaliveEngine = class {
190
191
  if (this.statusTimer) clearInterval(this.statusTimer);
191
192
  this.statusTimer = void 0;
192
193
  }
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;
194
+ /**
195
+ * True while a ping turn owns the session, so tool calls must be blocked. Becomes
196
+ * false as soon as a real user message joins the in-flight ping run: the rest of
197
+ * that run is real work and needs its tools.
198
+ */
199
+ shouldBlockTools(sessionID) {
200
+ const s = this.store.get(sessionID);
201
+ return !!s?.warming && !s.ping?.interrupted;
196
202
  }
197
203
  async onEvent(event) {
198
204
  const type = event?.type;
@@ -205,19 +211,31 @@ var KeepaliveEngine = class {
205
211
  }
206
212
  return;
207
213
  }
214
+ if (type === "message.part.updated") {
215
+ const part = event.properties?.part;
216
+ const sessionID = part?.sessionID ?? event.properties?.sessionID;
217
+ if (!sessionID) return;
218
+ const session = this.store.get(sessionID);
219
+ if (session?.warming) this.notePingRunPart(session, part);
220
+ return;
221
+ }
208
222
  if (type === "message.updated") {
209
223
  const info = event.properties?.info;
210
224
  const sessionID = info?.sessionID ?? event.properties?.sessionID;
211
225
  if (!sessionID) return;
212
226
  const session = this.store.get(sessionID);
213
- if (session?.warming) return;
227
+ if (session?.warming) {
228
+ if (info?.role === "user") this.notePingRunUser(session, info);
229
+ return;
230
+ }
214
231
  const eventAt = info?.time?.completed ?? info?.time?.created;
215
232
  if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
216
233
  return;
217
234
  if (info?.role === "user") {
218
235
  const s = this.store.ensure(sessionID);
236
+ if (!isNewUserMessage(s, info)) return;
237
+ noteUserMessage(s, info);
219
238
  s.busy = true;
220
- s.busySince = Date.now();
221
239
  s.active = false;
222
240
  this.store.persist();
223
241
  return;
@@ -242,7 +260,6 @@ var KeepaliveEngine = class {
242
260
  }
243
261
  const s = this.store.ensure(sessionID);
244
262
  s.busy = true;
245
- s.busySince = Date.now();
246
263
  s.active = false;
247
264
  this.store.persist();
248
265
  return;
@@ -254,6 +271,55 @@ var KeepaliveEngine = class {
254
271
  await this.armWindow(sessionID);
255
272
  }
256
273
  }
274
+ /**
275
+ * Track a new user message observed while a ping is in flight. Event order alone
276
+ * cannot tell the ping's own message from a real prompt racing it, so messages are
277
+ * held until their text part arrives (see `notePingRunPart`). Any message seen
278
+ * before the ping is sent, or a second new message, is necessarily a real turn.
279
+ */
280
+ notePingRunUser(s, info) {
281
+ const ping = s.ping;
282
+ if (!ping || typeof info?.id !== "string") return;
283
+ const created = info?.time?.created;
284
+ if (typeof created === "number" && created < ping.startedAt) return;
285
+ if (info.id === ping.messageID || ping.pending.has(info.id)) return;
286
+ if (!ping.sent || ping.messageID) {
287
+ this.interruptPing(s, info);
288
+ return;
289
+ }
290
+ ping.pending.set(info.id, info);
291
+ if (ping.pending.size > 1) this.interruptPing(s);
292
+ }
293
+ /**
294
+ * Classify a pending user message by its first text part: the ping token marks
295
+ * the ping's own message; any other text is a real turn joining the ping run.
296
+ * opencode publishes a user message's parts right after the message itself, before
297
+ * the run reaches any tool call.
298
+ */
299
+ notePingRunPart(s, part) {
300
+ const ping = s.ping;
301
+ if (!ping || part?.type !== "text" || part?.synthetic) return;
302
+ const id = part?.messageID;
303
+ if (typeof id !== "string" || !ping.pending.has(id)) return;
304
+ const info = ping.pending.get(id);
305
+ ping.pending.delete(id);
306
+ if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
307
+ ping.messageID = id;
308
+ return;
309
+ }
310
+ this.interruptPing(s, info);
311
+ }
312
+ /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
313
+ interruptPing(s, info) {
314
+ const ping = s.ping;
315
+ if (!ping) return;
316
+ if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`);
317
+ ping.interrupted = true;
318
+ if (info) noteUserMessage(s, info);
319
+ s.busy = true;
320
+ s.active = false;
321
+ this.store.persist();
322
+ }
257
323
  async armWindow(sessionID) {
258
324
  const s = this.store.ensure(sessionID);
259
325
  if (s.active && !s.busy) return;
@@ -261,7 +327,6 @@ var KeepaliveEngine = class {
261
327
  s.arming = true;
262
328
  try {
263
329
  s.busy = false;
264
- s.busySince = void 0;
265
330
  await this.resolveSession(s);
266
331
  if (s.busy || !this.enabled || !s.eligible) {
267
332
  s.active = false;
@@ -269,30 +334,37 @@ var KeepaliveEngine = class {
269
334
  return;
270
335
  }
271
336
  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;
337
+ if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
338
+ s.active = false;
339
+ this.store.persist();
340
+ this.logger.dbg(`not armed ${short(sessionID)} \u2014 no recent real response`);
341
+ return;
342
+ }
343
+ this.openWindow(s, now);
279
344
  this.store.persist();
280
345
  this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
281
346
  } finally {
282
347
  s.arming = false;
283
348
  }
284
349
  }
350
+ /** Start a warm window anchored on the last real response and the last cache touch. */
351
+ openWindow(s, now) {
352
+ s.idleSince = now;
353
+ s.windowEndsAt = s.lastResponseAt + this.config.windowMs;
354
+ s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.config.intervalMs);
355
+ s.pingsSent = 0;
356
+ s.lastPing = void 0;
357
+ s.active = true;
358
+ }
359
+ /** False once the cache has gone too long without a request to still be trusted warm. */
360
+ cacheMayBeWarm(s, now) {
361
+ const touch = lastCacheTouch(s);
362
+ return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.config.intervalMs;
363
+ }
285
364
  async tick() {
286
365
  if (!this.enabled) return;
287
366
  const now = Date.now();
288
367
  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
368
  if (!s.eligible || !s.active || s.busy || s.warming) continue;
297
369
  if (now >= s.windowEndsAt) {
298
370
  s.active = false;
@@ -302,22 +374,65 @@ var KeepaliveEngine = class {
302
374
  );
303
375
  continue;
304
376
  }
305
- if (now >= s.nextPingAt) void this.firePing(s.sessionID);
377
+ if (now < s.nextPingAt) continue;
378
+ if (!this.cacheMayBeWarm(s, now)) {
379
+ s.active = false;
380
+ this.store.persist();
381
+ this.logger.dbg(
382
+ `window closed ${short(s.sessionID)} \u2014 ping overdue, cache presumed cold`
383
+ );
384
+ continue;
385
+ }
386
+ void this.firePing(s.sessionID);
306
387
  }
307
388
  }
308
389
  async firePing(sessionID) {
309
390
  const s = this.store.get(sessionID);
310
- if (!s || s.warming || s.busy) return;
391
+ if (!s || s.warming || s.busy || !s.active) return;
392
+ const ping = {
393
+ startedAt: Date.now(),
394
+ sent: false,
395
+ pending: /* @__PURE__ */ new Map(),
396
+ interrupted: false
397
+ };
311
398
  s.warming = true;
312
- s.pingsSent += 1;
399
+ s.ping = ping;
313
400
  this.store.persist();
314
- const startAt = Date.now();
315
401
  try {
402
+ if (!await this.isIdleOnServer(sessionID)) {
403
+ s.busy = true;
404
+ s.active = false;
405
+ this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 session busy`);
406
+ return;
407
+ }
408
+ const request = s.request ?? await this.lookupRequest(s);
409
+ if (!request) {
410
+ s.active = false;
411
+ this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 last real request unknown`);
412
+ return;
413
+ }
414
+ if (ping.interrupted || s.busy || !s.active || !this.enabled) return;
415
+ ping.sent = true;
416
+ s.pingsSent += 1;
417
+ this.store.persist();
316
418
  const res = await this.client.session.prompt({
317
419
  path: { id: sessionID },
318
- body: { parts: [{ type: "text", text: this.config.pingToken }] }
420
+ body: {
421
+ parts: [{ type: "text", text: this.config.pingToken }],
422
+ ...request.agent ? { agent: request.agent } : {},
423
+ model: { providerID: request.providerID, modelID: request.modelID },
424
+ ...request.variant ? { variant: request.variant } : {}
425
+ }
319
426
  });
320
427
  const info = res?.data?.info ?? res?.info;
428
+ if (ping.interrupted) {
429
+ const completed = info?.time?.completed;
430
+ if (typeof completed === "number") s.lastResponseAt = completed;
431
+ this.logger.dbg(
432
+ `ping ${short(sessionID)} absorbed a real turn \u2014 kept, not reverted`
433
+ );
434
+ return;
435
+ }
321
436
  const cache = info?.tokens?.cache ?? {};
322
437
  const record = {
323
438
  at: Date.now(),
@@ -331,40 +446,94 @@ var KeepaliveEngine = class {
331
446
  this.logger.dbg(
332
447
  `ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`
333
448
  );
334
- if (this.config.revertPing) await this.revertPing(sessionID, startAt);
449
+ if (this.config.revertPing) await this.revertPing(sessionID, ping);
335
450
  } catch (err) {
336
451
  this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
337
452
  } finally {
338
453
  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
- );
454
+ s.ping = void 0;
455
+ const now = Date.now();
456
+ if (ping.sent) {
457
+ s.lastPingAt = now;
458
+ const next = s.nextPingAt + jitter(this.config.intervalMs);
459
+ s.nextPingAt = next > now ? next : now + jitter(this.config.intervalMs);
460
+ }
344
461
  this.store.persist();
462
+ if (ping.sent && ping.interrupted) {
463
+ s.busy = false;
464
+ s.active = false;
465
+ void this.armWindow(sessionID);
466
+ }
345
467
  }
346
468
  }
347
- /** Remove the `~` user + `~` assistant turn so it never enters real context. */
348
- async revertPing(sessionID, startAt) {
349
- let messages;
469
+ /** True only when the server positively reports the session idle. */
470
+ async isIdleOnServer(sessionID) {
350
471
  try {
351
- const res = await this.client.session.messages({ path: { id: sessionID } });
352
- messages = res?.data ?? res ?? [];
472
+ const res = await this.client.session.status();
473
+ const statuses = res?.data ?? res ?? {};
474
+ return (statuses?.[sessionID]?.type ?? "idle") === "idle";
353
475
  } catch (err) {
354
- this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err));
355
- return;
476
+ this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err));
477
+ return false;
478
+ }
479
+ }
480
+ /** Recover the last real turn's request settings when no user event was observed. */
481
+ async lookupRequest(s) {
482
+ const messages = await this.recentMessages(s.sessionID);
483
+ if (!messages) return void 0;
484
+ for (let i = messages.length - 1; i >= 0; i--) {
485
+ const m = messages[i];
486
+ if (m?.info?.role !== "user" || this.isPingMessage(m)) continue;
487
+ const request = requestFrom(m.info);
488
+ if (request) {
489
+ s.request = request;
490
+ return request;
491
+ }
356
492
  }
357
- let target;
358
- for (const m of messages) {
493
+ return void 0;
494
+ }
495
+ async recentMessages(sessionID) {
496
+ try {
497
+ const res = await this.client.session.messages({
498
+ path: { id: sessionID },
499
+ query: { limit: MESSAGE_LOOKUP_LIMIT }
500
+ });
501
+ const messages = res?.data ?? res;
502
+ return Array.isArray(messages) ? messages : void 0;
503
+ } catch (err) {
504
+ this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err));
505
+ return void 0;
506
+ }
507
+ }
508
+ isPingMessage(m) {
509
+ const text = (m?.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
510
+ return text === this.config.pingToken;
511
+ }
512
+ /**
513
+ * Remove the `~` user + `~` assistant turn so it never enters real context.
514
+ * Revert drops the target and everything after it (and rolls back file
515
+ * snapshots), so it is skipped unless only the ping's own replies follow.
516
+ */
517
+ async revertPing(sessionID, ping) {
518
+ const messages = await this.recentMessages(sessionID);
519
+ if (!messages) return;
520
+ let index = -1;
521
+ for (let i = 0; i < messages.length; i++) {
522
+ const m = messages[i];
359
523
  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;
524
+ if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
525
+ index = i;
363
526
  }
364
- if (!target) {
527
+ if (index < 0) {
365
528
  this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
366
529
  return;
367
530
  }
531
+ const target = messages[index].info.id;
532
+ const foreign = messages.slice(index + 1).some((m) => m?.info?.role !== "assistant" || m?.info?.parentID !== target);
533
+ if (foreign) {
534
+ this.logger.warn(`ping ${short(sessionID)} not reverted \u2014 other messages followed it`);
535
+ return;
536
+ }
368
537
  try {
369
538
  await this.client.session.revert({
370
539
  path: { id: sessionID },
@@ -374,6 +543,9 @@ var KeepaliveEngine = class {
374
543
  this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err));
375
544
  }
376
545
  }
546
+ isRecentPing(m, ping) {
547
+ return Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2e3 && this.isPingMessage(m);
548
+ }
377
549
  async resolveSession(s) {
378
550
  try {
379
551
  const res = await this.client.session.get({ path: { id: s.sessionID } });
@@ -404,13 +576,8 @@ var KeepaliveEngine = class {
404
576
  continue;
405
577
  }
406
578
  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;
579
+ if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue;
580
+ this.openWindow(session, now);
414
581
  }
415
582
  this.store.persist();
416
583
  this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`);
@@ -435,6 +602,34 @@ var KeepaliveEngine = class {
435
602
  }
436
603
  }
437
604
  };
605
+ function lastCacheTouch(s) {
606
+ return Math.max(s.lastResponseAt, s.lastPingAt ?? 0);
607
+ }
608
+ function isNewUserMessage(s, info) {
609
+ if (typeof info?.id === "string" && info.id === s.lastUserID) return false;
610
+ const created = info?.time?.created;
611
+ return typeof created !== "number" || created >= (s.lastUserAt ?? 0);
612
+ }
613
+ function noteUserMessage(s, info) {
614
+ if (typeof info?.id === "string") s.lastUserID = info.id;
615
+ const created = info?.time?.created;
616
+ if (typeof created === "number") s.lastUserAt = created;
617
+ const request = requestFrom(info);
618
+ if (request) s.request = request;
619
+ }
620
+ function requestFrom(info) {
621
+ const model = info?.model;
622
+ const providerID = model?.providerID;
623
+ const modelID = model?.modelID ?? model?.id;
624
+ if (typeof providerID !== "string" || typeof modelID !== "string") return void 0;
625
+ const variant = model?.variant ?? info?.variant;
626
+ return {
627
+ agent: typeof info?.agent === "string" ? info.agent : void 0,
628
+ providerID,
629
+ modelID,
630
+ variant: typeof variant === "string" && variant !== "default" ? variant : void 0
631
+ };
632
+ }
438
633
  function short(sessionID) {
439
634
  return sessionID.slice(-6);
440
635
  }
@@ -529,7 +724,10 @@ var KeepaliveStore = class {
529
724
  all() {
530
725
  return [...this.sessions.values()];
531
726
  }
532
- /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */
727
+ /**
728
+ * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
729
+ * returned promise resolves once this snapshot and every earlier one are written.
730
+ */
533
731
  persist() {
534
732
  const snapshot = {
535
733
  version: 1,
@@ -559,6 +757,7 @@ var KeepaliveStore = class {
559
757
  };
560
758
  const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
561
759
  this.writeQueue = this.writeQueue.then(write, write);
760
+ return this.writeQueue;
562
761
  }
563
762
  };
564
763
  async function atomicWrite(path, contents) {
@@ -596,7 +795,7 @@ var server = async (ctx, options) => {
596
795
  const store = new KeepaliveStore(config, ctx.directory);
597
796
  const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory);
598
797
  engine.start();
599
- const version = true ? "0.1.4" : "dev";
798
+ const version = true ? "0.1.5" : "dev";
600
799
  logger.info(`v${version} ready`);
601
800
  const instruction = keepaliveInstruction(config.pingToken);
602
801
  return {
@@ -614,7 +813,7 @@ var server = async (ctx, options) => {
614
813
  // Backstop: if the model ignores the instruction and tries a tool during a
615
814
  // ping, block it — the ping turn is going to be reverted anyway.
616
815
  "tool.execute.before": async (input) => {
617
- if (engine.isWarming(input.sessionID)) {
816
+ if (engine.shouldBlockTools(input.sessionID)) {
618
817
  throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
619
818
  }
620
819
  },