@vikrant82/opencode-cache-keepalive 0.1.5 → 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.
@@ -31,7 +31,10 @@ export declare class KeepaliveEngine {
31
31
  private controlUpdatedAt;
32
32
  private reconcilingStatus;
33
33
  private enabled;
34
+ private intervalOverride;
34
35
  constructor(client: any, config: KeepaliveConfig, store: KeepaliveStore, logger: Logger, directory: string);
36
+ /** Effective ping interval: the runtime override from the control file, else config. */
37
+ private get intervalMs();
35
38
  start(): void;
36
39
  stop(): void;
37
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"keepalive.d.ts","sourceRoot":"","sources":["../../lib/keepalive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,OAAO,KAAK,EAAgB,cAAc,EAAiC,MAAM,SAAS,CAAA;AAC1F,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAgBtC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,eAAe;IASpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAZ9B,OAAO,CAAC,KAAK,CAA4C;IACzD,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,WAAW,CAA4C;IAC/D,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,iBAAiB,CAAQ;IACjC,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM;IAQtC,KAAK,IAAI,IAAI;IAab,IAAI,IAAI,IAAI;IAUZ;;;;OAIG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAKtC,OAAO,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAiGxC;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB,0FAA0F;IAC1F,OAAO,CAAC,aAAa;YAWP,SAAS;IAuCvB,uFAAuF;IACvF,OAAO,CAAC,UAAU;IASlB,yFAAyF;IACzF,OAAO,CAAC,cAAc;YAKR,IAAI;YAgCJ,QAAQ;IAsGtB,qEAAqE;YACvD,cAAc;IAW5B,qFAAqF;YACvE,aAAa;YAeb,cAAc;IAc5B,OAAO,CAAC,aAAa;IASrB;;;;OAIG;YACW,UAAU;IAoCxB,OAAO,CAAC,YAAY;YAMN,cAAc;IAiB5B,OAAO,CAAC,WAAW;IAwBnB,sFAAsF;YACxE,eAAe;CAmBhC"}
1
+ {"version":3,"file":"keepalive.d.ts","sourceRoot":"","sources":["../../lib/keepalive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,OAAO,KAAK,EAAgB,cAAc,EAAiC,MAAM,SAAS,CAAA;AAC1F,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAgBtC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,eAAe;IAUpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAb9B,OAAO,CAAC,KAAK,CAA4C;IACzD,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,WAAW,CAA4C;IAC/D,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,iBAAiB,CAAQ;IACjC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAoB;gBAGvB,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM;IAUtC,wFAAwF;IACxF,OAAO,KAAK,UAAU,GAErB;IAED,KAAK,IAAI,IAAI;IAab,IAAI,IAAI,IAAI;IAUZ;;;;OAIG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAKtC,OAAO,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAiGxC;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAcvB,0FAA0F;IAC1F,OAAO,CAAC,aAAa;YAWP,SAAS;IAuCvB,uFAAuF;IACvF,OAAO,CAAC,UAAU;IASlB,yFAAyF;IACzF,OAAO,CAAC,cAAc;YAKR,IAAI;YAgCJ,QAAQ;IAsGtB,qEAAqE;YACvD,cAAc;IAW5B,qFAAqF;YACvE,aAAa;YAeb,cAAc;IAc5B,OAAO,CAAC,aAAa;IASrB;;;;OAIG;YACW,UAAU;IAoCxB,OAAO,CAAC,YAAY;YAMN,cAAc;IAiB5B,OAAO,CAAC,WAAW;IA6CnB,sFAAsF;YACxE,eAAe;CAmBhC"}
@@ -103,8 +103,11 @@ export declare class KeepaliveStore {
103
103
  private readonly sessions;
104
104
  private writeQueue;
105
105
  private enabled;
106
+ private intervalMs;
106
107
  constructor(config: KeepaliveConfig, directory: string);
107
108
  setEnabled(enabled: boolean): void;
109
+ /** Effective ping interval (runtime override or config) reported to the TUI. */
110
+ setIntervalMs(intervalMs: number): void;
108
111
  get(sessionID: string): SessionKeepalive | undefined;
109
112
  ensure(sessionID: string): SessionKeepalive;
110
113
  remove(sessionID: string): void;
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,10 @@
1
+ /** @jsxImportSource @opentui/solid */
1
2
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
+ /**
4
+ * Register the keepalive commands. The control file is scoped per directory, so
5
+ * toggling keepalive or changing its interval here only affects this project's
6
+ * server plugin — not other open opencode sessions. The server polls the file
7
+ * every second, so changes apply without a restart.
8
+ */
2
9
  export declare function registerKeepaliveCommands(api: TuiPluginApi): void;
3
10
  //# sourceMappingURL=commands.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../../lib/tui/commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAG3D,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAmDjE"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../../lib/tui/commands.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC,OAAO,KAAK,EAAyB,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAqBlF;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CA0IjE"}
@@ -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,OA+G7F"}
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;AAYhC;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,OAwI7F"}
@@ -1,3 +1,11 @@
1
1
  export declare function mmss(ms: number): string;
2
2
  export declare function kfmt(n: number): string;
3
+ /**
4
+ * Parse a human duration such as "4m30s", "4.5m", "270s", "1h" or "28m 20s".
5
+ * A bare number means minutes. Returns undefined for anything unparseable;
6
+ * range checks are the caller's concern.
7
+ */
8
+ export declare function parseDuration(input: string): number | undefined;
9
+ /** Compact duration label, e.g. 270000 -> "4m 30s", 3600000 -> "1h". */
10
+ export declare function formatDuration(ms: number): string;
3
11
  //# sourceMappingURL=format.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../lib/tui/format.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAKvC;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAKtC"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../lib/tui/format.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAKvC;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAKtC;AAID;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAa/D;AAED,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAWjD"}
package/lib/control.ts CHANGED
@@ -1,37 +1,122 @@
1
+ import { randomUUID } from "node:crypto"
1
2
  import { readFileSync } from "node:fs"
2
3
  import { mkdir, rename, writeFile } from "node:fs/promises"
3
4
  import { dirname } from "node:path"
4
5
  import { controlFilePath } from "./paths"
5
6
 
7
+ /** Smallest accepted runtime ping interval; below this the 15 s scheduler tick dominates. */
8
+ export const MIN_INTERVAL_MS = 60_000
9
+ /** Largest accepted runtime ping interval (sanity bound; no provider cache outlives it). */
10
+ export const MAX_INTERVAL_MS = 24 * 3_600_000
11
+
12
+ /**
13
+ * Per-project runtime overrides written by the TUI and polled by the server plugin.
14
+ * Absent fields mean "use the plugin configuration". Readers ignore unknown fields,
15
+ * so older servers still honour `enabled` and ignore `intervalMs`.
16
+ */
6
17
  export type KeepaliveControl = {
7
18
  version: 1
8
- enabled: boolean
19
+ /** Runtime on/off override. */
20
+ enabled?: boolean
21
+ /** Runtime ping interval override in milliseconds. */
22
+ intervalMs?: number
23
+ /** Epoch ms of the last write; strictly increasing so every write is observed. */
9
24
  updatedAt: number
10
25
  }
11
26
 
27
+ /** Changes to apply to the control file; `intervalMs: null` clears the override. */
28
+ export type ControlPatch = {
29
+ enabled?: boolean
30
+ intervalMs?: number | null
31
+ }
32
+
33
+ export function isValidInterval(ms: unknown): ms is number {
34
+ return (
35
+ typeof ms === "number" &&
36
+ Number.isFinite(ms) &&
37
+ ms >= MIN_INTERVAL_MS &&
38
+ ms <= MAX_INTERVAL_MS
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Read the directory's control file. Returns undefined when it is missing or
44
+ * malformed; an out-of-range interval override is dropped rather than rejecting
45
+ * the whole file. Never throws.
46
+ */
12
47
  export function readControl(directory: string): KeepaliveControl | undefined {
13
48
  try {
14
- const value = JSON.parse(
15
- readFileSync(controlFilePath(directory), "utf8"),
16
- ) as Partial<KeepaliveControl>
17
- if (value.version !== 1 || typeof value.enabled !== "boolean") return undefined
18
- if (typeof value.updatedAt !== "number") return undefined
19
- return value as KeepaliveControl
49
+ const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8")) as Record<
50
+ string,
51
+ unknown
52
+ >
53
+ if (value?.version !== 1 || typeof value.updatedAt !== "number") return undefined
54
+ if (value.enabled !== undefined && typeof value.enabled !== "boolean") return undefined
55
+ return {
56
+ version: 1,
57
+ updatedAt: value.updatedAt,
58
+ ...(typeof value.enabled === "boolean" ? { enabled: value.enabled } : {}),
59
+ ...(isValidInterval(value.intervalMs) ? { intervalMs: value.intervalMs } : {}),
60
+ }
20
61
  } catch {
21
62
  return undefined
22
63
  }
23
64
  }
24
65
 
25
- export async function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl> {
66
+ /**
67
+ * Merge `patch` into the directory's control file and write it atomically. Fields
68
+ * not in the patch keep their current value.
69
+ *
70
+ * @throws RangeError when `patch.intervalMs` is outside
71
+ * [MIN_INTERVAL_MS, MAX_INTERVAL_MS]; filesystem errors propagate.
72
+ */
73
+ export async function updateControl(
74
+ directory: string,
75
+ patch: ControlPatch,
76
+ ): Promise<KeepaliveControl> {
77
+ if (
78
+ patch.intervalMs !== undefined &&
79
+ patch.intervalMs !== null &&
80
+ !isValidInterval(patch.intervalMs)
81
+ ) {
82
+ throw new RangeError(
83
+ `Ping interval must be between ${MIN_INTERVAL_MS / 1000}s and ${MAX_INTERVAL_MS / 3_600_000}h`,
84
+ )
85
+ }
86
+
87
+ // Serialize read-merge-write within this process so concurrent updates cannot
88
+ // drop each other's fields. Across processes the atomic rename keeps the file
89
+ // valid; the last writer wins.
90
+ const run = updateQueue.then(() => writeMerged(directory, patch))
91
+ updateQueue = run.then(
92
+ () => undefined,
93
+ () => undefined,
94
+ )
95
+ return run
96
+ }
97
+
98
+ let updateQueue: Promise<void> = Promise.resolve()
99
+
100
+ async function writeMerged(directory: string, patch: ControlPatch): Promise<KeepaliveControl> {
101
+ const current = readControl(directory)
102
+ const enabled = patch.enabled ?? current?.enabled
103
+ const intervalMs =
104
+ patch.intervalMs === null ? undefined : (patch.intervalMs ?? current?.intervalMs)
26
105
  const value: KeepaliveControl = {
27
106
  version: 1,
28
- enabled,
29
- updatedAt: Date.now(),
107
+ ...(enabled !== undefined ? { enabled } : {}),
108
+ ...(intervalMs !== undefined ? { intervalMs } : {}),
109
+ updatedAt: Math.max(Date.now(), (current?.updatedAt ?? 0) + 1),
30
110
  }
31
111
  const path = controlFilePath(directory)
32
- const tmp = `${path}.${process.pid}.${value.updatedAt}.tmp`
112
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
33
113
  await mkdir(dirname(path), { recursive: true })
34
114
  await writeFile(tmp, `${JSON.stringify(value)}\n`, "utf8")
35
115
  await rename(tmp, path)
36
116
  return value
37
117
  }
118
+
119
+ /** Set the runtime on/off override, preserving any interval override. */
120
+ export function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl> {
121
+ return updateControl(directory, { enabled })
122
+ }
package/lib/keepalive.ts CHANGED
@@ -43,6 +43,7 @@ export class KeepaliveEngine {
43
43
  private controlUpdatedAt = 0
44
44
  private reconcilingStatus = false
45
45
  private enabled: boolean
46
+ private intervalOverride: number | undefined
46
47
 
47
48
  constructor(
48
49
  private readonly client: any,
@@ -53,8 +54,15 @@ export class KeepaliveEngine {
53
54
  ) {
54
55
  const control = readControl(directory)
55
56
  this.enabled = control?.enabled ?? config.enabled
57
+ this.intervalOverride = control?.intervalMs
56
58
  this.controlUpdatedAt = control?.updatedAt ?? 0
57
59
  this.store.setEnabled(this.enabled)
60
+ this.store.setIntervalMs(this.intervalMs)
61
+ }
62
+
63
+ /** Effective ping interval: the runtime override from the control file, else config. */
64
+ private get intervalMs(): number {
65
+ return this.intervalOverride ?? this.config.intervalMs
58
66
  }
59
67
 
60
68
  start(): void {
@@ -65,7 +73,7 @@ export class KeepaliveEngine {
65
73
  this.store.persist()
66
74
  this.logger.info(
67
75
  `started (${this.enabled ? "enabled" : "disabled"}, ` +
68
- `interval=${Math.round(this.config.intervalMs / 1000)}s, ` +
76
+ `interval=${Math.round(this.intervalMs / 1000)}s, ` +
69
77
  `window=${Math.round(this.config.windowMs / 60_000)}m)`,
70
78
  )
71
79
  }
@@ -282,7 +290,7 @@ export class KeepaliveEngine {
282
290
  private openWindow(s: SessionKeepalive, now: number): void {
283
291
  s.idleSince = now
284
292
  s.windowEndsAt = s.lastResponseAt + this.config.windowMs
285
- s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.config.intervalMs)
293
+ s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs)
286
294
  s.pingsSent = 0
287
295
  s.lastPing = undefined
288
296
  s.active = true
@@ -291,7 +299,7 @@ export class KeepaliveEngine {
291
299
  /** False once the cache has gone too long without a request to still be trusted warm. */
292
300
  private cacheMayBeWarm(s: SessionKeepalive, now: number): boolean {
293
301
  const touch = lastCacheTouch(s)
294
- return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.config.intervalMs
302
+ return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs
295
303
  }
296
304
 
297
305
  private async tick(): Promise<void> {
@@ -415,8 +423,8 @@ export class KeepaliveEngine {
415
423
  // Schedule relative to the intended time so ping duration doesn't
416
424
  // compound drift, but never into the past: that would fire catch-up
417
425
  // pings on consecutive ticks.
418
- const next = s.nextPingAt + jitter(this.config.intervalMs)
419
- s.nextPingAt = next > now ? next : now + jitter(this.config.intervalMs)
426
+ const next = s.nextPingAt + jitter(this.intervalMs)
427
+ s.nextPingAt = next > now ? next : now + jitter(this.intervalMs)
420
428
  }
421
429
  this.store.persist()
422
430
  if (ping.sent && ping.interrupted) {
@@ -547,24 +555,45 @@ export class KeepaliveEngine {
547
555
  const control = readControl(this.directory)
548
556
  if (!control || control.updatedAt <= this.controlUpdatedAt) return
549
557
  this.controlUpdatedAt = control.updatedAt
550
- if (control.enabled === this.enabled) return
551
-
552
- this.enabled = control.enabled
553
- this.store.setEnabled(control.enabled)
554
558
  const now = Date.now()
555
- for (const session of this.store.all()) {
556
- if (!control.enabled) {
557
- session.active = false
558
- continue
559
+ let changed = false
560
+
561
+ if (control.intervalMs !== this.intervalOverride) {
562
+ this.intervalOverride = control.intervalMs
563
+ this.store.setIntervalMs(this.intervalMs)
564
+ // Re-anchor pending pings on the new cadence. An in-flight ping schedules
565
+ // its successor with the new interval when it finishes.
566
+ for (const session of this.store.all()) {
567
+ if (session.active && !session.warming)
568
+ session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs)
569
+ }
570
+ this.logger.info(
571
+ `runtime interval ${Math.round(this.intervalMs / 1000)}s` +
572
+ (control.intervalMs === undefined ? " (config)" : ""),
573
+ )
574
+ changed = true
575
+ }
576
+
577
+ const enabled = control.enabled ?? this.config.enabled
578
+ if (enabled !== this.enabled) {
579
+ this.enabled = enabled
580
+ this.store.setEnabled(enabled)
581
+ for (const session of this.store.all()) {
582
+ if (!enabled) {
583
+ session.active = false
584
+ continue
585
+ }
586
+ // Only re-arm sessions with a real response whose cache may still be warm,
587
+ // and that are not currently occupied.
588
+ if (!session.eligible || session.busy || session.warming) continue
589
+ if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
590
+ this.openWindow(session, now)
559
591
  }
560
- // Only re-arm sessions with a real response whose cache may still be warm,
561
- // and that are not currently occupied.
562
- if (!session.eligible || session.busy || session.warming) continue
563
- if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
564
- this.openWindow(session, now)
592
+ this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`)
593
+ changed = true
565
594
  }
566
- this.store.persist()
567
- this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
595
+
596
+ if (changed) this.store.persist()
568
597
  }
569
598
 
570
599
  /** Recover when a session.status idle event is missed by reconciling with the API. */
package/lib/state.ts CHANGED
@@ -111,18 +111,25 @@ export class KeepaliveStore {
111
111
  private readonly sessions = new Map<string, SessionKeepalive>()
112
112
  private writeQueue: Promise<void> = Promise.resolve()
113
113
  private enabled: boolean
114
+ private intervalMs: number
114
115
 
115
116
  constructor(
116
117
  private readonly config: KeepaliveConfig,
117
118
  private readonly directory: string,
118
119
  ) {
119
120
  this.enabled = config.enabled
121
+ this.intervalMs = config.intervalMs
120
122
  }
121
123
 
122
124
  setEnabled(enabled: boolean): void {
123
125
  this.enabled = enabled
124
126
  }
125
127
 
128
+ /** Effective ping interval (runtime override or config) reported to the TUI. */
129
+ setIntervalMs(intervalMs: number): void {
130
+ this.intervalMs = intervalMs
131
+ }
132
+
126
133
  get(sessionID: string): SessionKeepalive | undefined {
127
134
  return this.sessions.get(sessionID)
128
135
  }
@@ -163,7 +170,7 @@ export class KeepaliveStore {
163
170
  version: 1,
164
171
  updatedAt: Date.now(),
165
172
  enabled: this.enabled,
166
- intervalMs: this.config.intervalMs,
173
+ intervalMs: this.intervalMs,
167
174
  windowMs: this.config.windowMs,
168
175
  sessions: Object.fromEntries(
169
176
  this.all().map((s) => [
@@ -175,7 +182,7 @@ export class KeepaliveStore {
175
182
  idleSince: s.idleSince,
176
183
  windowEndsAt: s.windowEndsAt,
177
184
  nextPingAt: s.nextPingAt,
178
- intervalMs: this.config.intervalMs,
185
+ intervalMs: this.intervalMs,
179
186
  pingsSent: s.pingsSent,
180
187
  busy: s.busy,
181
188
  warming: s.warming,
@@ -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}>