@vikrant82/opencode-cache-keepalive 0.1.1 → 0.1.3

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
@@ -109,18 +109,19 @@ function stateDirectoryPath() {
109
109
  "keepalive"
110
110
  );
111
111
  }
112
- function controlFilePath() {
113
- return join(stateDirectoryPath(), "control.json");
112
+ function controlFilePath(directory) {
113
+ const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
114
+ return join(stateDirectoryPath(), `control-${key}.json`);
114
115
  }
115
116
  function logFilePath() {
116
117
  return join(stateDirectoryPath(), "server.log");
117
118
  }
118
119
 
119
120
  // lib/control.ts
120
- function readControl() {
121
+ function readControl(directory) {
121
122
  try {
122
123
  const value = JSON.parse(
123
- readFileSync(controlFilePath(), "utf8")
124
+ readFileSync(controlFilePath(directory), "utf8")
124
125
  );
125
126
  if (value.version !== 1 || typeof value.enabled !== "boolean") return void 0;
126
127
  if (typeof value.updatedAt !== "number") return void 0;
@@ -146,13 +147,15 @@ var JITTER_MS = 15e3;
146
147
  var CONTROL_POLL_MS = 1e3;
147
148
  var STATUS_POLL_MS = 2e3;
148
149
  var PING_STATUS_GRACE_MS = 5e3;
150
+ var BUSY_TIMEOUT_MS = 3e5;
149
151
  var KeepaliveEngine = class {
150
- constructor(client, config, store, logger) {
152
+ constructor(client, config, store, logger, directory) {
151
153
  this.client = client;
152
154
  this.config = config;
153
155
  this.store = store;
154
156
  this.logger = logger;
155
- const control = readControl();
157
+ this.directory = directory;
158
+ const control = readControl(directory);
156
159
  this.enabled = control?.enabled ?? config.enabled;
157
160
  this.controlUpdatedAt = control?.updatedAt ?? 0;
158
161
  this.store.setEnabled(this.enabled);
@@ -161,6 +164,7 @@ var KeepaliveEngine = class {
161
164
  config;
162
165
  store;
163
166
  logger;
167
+ directory;
164
168
  timer;
165
169
  controlTimer;
166
170
  statusTimer;
@@ -213,6 +217,7 @@ var KeepaliveEngine = class {
213
217
  if (info?.role === "user") {
214
218
  const s = this.store.ensure(sessionID);
215
219
  s.busy = true;
220
+ s.busySince = Date.now();
216
221
  s.active = false;
217
222
  this.store.persist();
218
223
  return;
@@ -237,6 +242,7 @@ var KeepaliveEngine = class {
237
242
  }
238
243
  const s = this.store.ensure(sessionID);
239
244
  s.busy = true;
245
+ s.busySince = Date.now();
240
246
  s.active = false;
241
247
  this.store.persist();
242
248
  return;
@@ -251,28 +257,42 @@ var KeepaliveEngine = class {
251
257
  async armWindow(sessionID) {
252
258
  const s = this.store.ensure(sessionID);
253
259
  if (s.active && !s.busy) return;
254
- s.busy = false;
255
- await this.resolveSession(s);
256
- if (s.busy || !this.enabled || !s.eligible) {
257
- s.active = false;
260
+ if (s.arming) return;
261
+ s.arming = true;
262
+ try {
263
+ s.busy = false;
264
+ s.busySince = void 0;
265
+ await this.resolveSession(s);
266
+ if (s.busy || !this.enabled || !s.eligible) {
267
+ s.active = false;
268
+ this.store.persist();
269
+ return;
270
+ }
271
+ 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;
258
279
  this.store.persist();
259
- return;
280
+ this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
281
+ } finally {
282
+ s.arming = false;
260
283
  }
261
- const now = Date.now();
262
- if (!s.lastResponseAt) s.lastResponseAt = now;
263
- s.idleSince = now;
264
- s.windowEndsAt = now + this.config.windowMs;
265
- s.nextPingAt = now + this.config.intervalMs;
266
- s.pingsSent = 0;
267
- s.lastPing = void 0;
268
- s.active = true;
269
- this.store.persist();
270
- this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
271
284
  }
272
285
  async tick() {
273
286
  if (!this.enabled) return;
274
287
  const now = Date.now();
275
288
  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
+ }
276
296
  if (!s.eligible || !s.active || s.busy || s.warming) continue;
277
297
  if (now >= s.windowEndsAt) {
278
298
  s.active = false;
@@ -317,7 +337,10 @@ var KeepaliveEngine = class {
317
337
  } finally {
318
338
  s.warming = false;
319
339
  s.lastPingAt = Date.now();
320
- s.nextPingAt = Date.now() + jitter(this.config.intervalMs);
340
+ s.nextPingAt = Math.max(
341
+ Date.now() + 1e3,
342
+ s.nextPingAt + jitter(this.config.intervalMs)
343
+ );
321
344
  this.store.persist();
322
345
  }
323
346
  }
@@ -368,7 +391,7 @@ var KeepaliveEngine = class {
368
391
  }
369
392
  }
370
393
  pollControl() {
371
- const control = readControl();
394
+ const control = readControl(this.directory);
372
395
  if (!control || control.updatedAt <= this.controlUpdatedAt) return;
373
396
  this.controlUpdatedAt = control.updatedAt;
374
397
  if (control.enabled === this.enabled) return;
@@ -381,7 +404,7 @@ var KeepaliveEngine = class {
381
404
  continue;
382
405
  }
383
406
  if (!session.eligible || session.busy || session.warming) continue;
384
- if (!session.lastResponseAt) session.lastResponseAt = now;
407
+ if (!session.lastResponseAt) continue;
385
408
  session.idleSince = now;
386
409
  session.windowEndsAt = now + this.config.windowMs;
387
410
  session.nextPingAt = now + this.config.intervalMs;
@@ -571,9 +594,9 @@ var server = async (ctx, options) => {
571
594
  }
572
595
  if (isSecureMode()) configureClientAuth(ctx.client);
573
596
  const store = new KeepaliveStore(config, ctx.directory);
574
- const engine = new KeepaliveEngine(ctx.client, config, store, logger);
597
+ const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory);
575
598
  engine.start();
576
- const version = true ? "0.1.1" : "dev";
599
+ const version = true ? "0.1.3" : "dev";
577
600
  logger.info(`v${version} ready`);
578
601
  const instruction = keepaliveInstruction(config.pingToken);
579
602
  return {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../lib/auth.ts","../lib/config.ts","../lib/control.ts","../lib/paths.ts","../lib/model.ts","../lib/keepalive.ts","../lib/logger.ts","../lib/state.ts","../lib/system.ts","../index.ts"],"sourcesContent":["/**\n * When opencode runs with `OPENCODE_SERVER_PASSWORD`, the local HTTP API requires\n * Basic auth. The SDK client handed to plugins does not carry it automatically,\n * so we attach an interceptor. No-op when the server is not password protected.\n *\n * Same approach as opencode-dynamic-context-pruning's auth helper.\n */\nexport function isSecureMode(): boolean {\n return !!process.env.OPENCODE_SERVER_PASSWORD\n}\n\nexport function getAuthorizationHeader(): string | undefined {\n const password = process.env.OPENCODE_SERVER_PASSWORD\n if (!password) return undefined\n const username = process.env.OPENCODE_SERVER_USERNAME ?? \"opencode\"\n const credentials = Buffer.from(`${username}:${password}`).toString(\"base64\")\n return `Basic ${credentials}`\n}\n\nexport function configureClientAuth(client: any): void {\n const header = getAuthorizationHeader()\n if (!header) return\n\n const inner = client?._client ?? client?.client\n if (!inner?.interceptors?.request) return\n\n inner.interceptors.request.use((request: Request) => {\n if (!request.headers.has(\"Authorization\")) {\n request.headers.set(\"Authorization\", header)\n }\n return request\n })\n}\n","export type KeepaliveConfig = {\n /** Master switch. When false the plugin registers no timers or hooks. */\n enabled: boolean\n /** Milliseconds between pings. Must stay under the shortest supported provider TTL. */\n intervalMs: number\n /**\n * How long to keep warming after the last real response before giving up and\n * letting the cache go cold. The default follows the Copilot Claude\n * write/read break-even; providers with automatic caching may be cheaper.\n */\n windowMs: number\n /**\n * Append a stable keepalive instruction to the system prompt so the model\n * answers a `~` ping with a single `~` token and never calls tools. The\n * instruction is added to real turns too, so the cached prefix stays identical.\n */\n injectSystemInstruction: boolean\n /** The single character/token used as the keepalive suffix. */\n pingToken: string\n /** Remove synthetic ping turns after measuring their cache usage. */\n revertPing: boolean\n /** Warm subagent/child sessions too. Off by default to avoid wasted pings. */\n includeChildSessions: boolean\n debug: boolean\n /** providerID substrings eligible for warming (cache-controlled providers). */\n providerAllowlist: string[]\n /** modelID substrings eligible for warming. */\n modelAllowlist: string[]\n}\n\nconst DEFAULTS: KeepaliveConfig = {\n enabled: true,\n intervalMs: 270_000, // 4.5 min — below the shortest supported cache TTL\n windowMs: 3_300_000, // 55 min — the Copilot Claude write/read break-even\n injectSystemInstruction: true,\n pingToken: \"~\",\n revertPing: true,\n includeChildSessions: false,\n debug: false,\n providerAllowlist: [\"copilot\"],\n modelAllowlist: [\"claude\", \"anthropic\", \"sonnet\", \"opus\", \"haiku\", \"gpt\"],\n}\n\nexport function getConfig(options: Record<string, unknown> | undefined): KeepaliveConfig {\n const o = options ?? {}\n const env = process.env\n\n return {\n enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== \"false\" && DEFAULTS.enabled),\n intervalMs: durationOpt(\n o.intervalMs,\n o.intervalSeconds,\n numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs),\n ),\n windowMs: durationOpt(\n o.windowMs,\n o.windowMinutes ? Number(o.windowMinutes) * 60 : undefined,\n numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs),\n ),\n injectSystemInstruction: boolOpt(\n o.injectSystemInstruction,\n DEFAULTS.injectSystemInstruction,\n ),\n pingToken:\n typeof o.pingToken === \"string\" && o.pingToken.length > 0\n ? o.pingToken\n : DEFAULTS.pingToken,\n revertPing: boolOpt(\n o.revertPing,\n env.OPENCODE_KEEPALIVE_REVERT_PING !== \"false\" && DEFAULTS.revertPing,\n ),\n includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),\n debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === \"true\"),\n providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),\n modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist),\n }\n}\n\nfunction boolOpt(value: unknown, fallback: boolean): boolean {\n return typeof value === \"boolean\" ? value : fallback\n}\n\nfunction listOpt(value: unknown, fallback: string[]): string[] {\n if (!Array.isArray(value)) return fallback\n const filtered = value.filter((item): item is string => typeof item === \"string\")\n return filtered.length > 0 ? filtered : fallback\n}\n\n/** Accept an explicit `*Ms` option, a `*Seconds` option, or fall back. */\nfunction durationOpt(ms: unknown, seconds: unknown, fallback: number): number {\n if (typeof ms === \"number\" && Number.isFinite(ms) && ms > 0) return ms\n if (typeof seconds === \"number\" && Number.isFinite(seconds) && seconds > 0)\n return seconds * 1000\n return fallback\n}\n\nfunction numEnv(raw: string | undefined, fallback: number): number {\n if (!raw) return fallback\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? value : fallback\n}\n","import { readFileSync } from \"node:fs\"\nimport { mkdir, rename, writeFile } from \"node:fs/promises\"\nimport { dirname } from \"node:path\"\nimport { controlFilePath } from \"./paths\"\n\nexport type KeepaliveControl = {\n version: 1\n enabled: boolean\n updatedAt: number\n}\n\nexport function readControl(): KeepaliveControl | undefined {\n try {\n const value = JSON.parse(\n readFileSync(controlFilePath(), \"utf8\"),\n ) as Partial<KeepaliveControl>\n if (value.version !== 1 || typeof value.enabled !== \"boolean\") return undefined\n if (typeof value.updatedAt !== \"number\") return undefined\n return value as KeepaliveControl\n } catch {\n return undefined\n }\n}\n\nexport async function writeControl(enabled: boolean): Promise<KeepaliveControl> {\n const value: KeepaliveControl = {\n version: 1,\n enabled,\n updatedAt: Date.now(),\n }\n const path = controlFilePath()\n const tmp = `${path}.${process.pid}.${value.updatedAt}.tmp`\n await mkdir(dirname(path), { recursive: true })\n await writeFile(tmp, `${JSON.stringify(value)}\\n`, \"utf8\")\n await rename(tmp, path)\n return value\n}\n","import { createHash } from \"node:crypto\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\n/**\n * Absolute path to the JSON state file shared between the server plugin (writer)\n * and the TUI plugin (reader). Both processes run on the same machine, so a file\n * under opencode's plugin storage dir is the simplest cross-process channel.\n *\n * Mirrors the storage layout used by other opencode plugins:\n * <XDG_DATA_HOME | ~/.local/share>/opencode/storage/plugin/keepalive/state.json\n */\nexport function stateFilePath(directory: string): string {\n const key = createHash(\"sha256\").update(directory).digest(\"hex\").slice(0, 16)\n return join(stateDirectoryPath(), `state-${key}.json`)\n}\n\nexport function stateDirectoryPath(): string {\n return join(\n process.env.XDG_DATA_HOME ?? join(homedir(), \".local\", \"share\"),\n \"opencode\",\n \"storage\",\n \"plugin\",\n \"keepalive\",\n )\n}\n\n/** Runtime on/off control written by the TUI and read by the server plugin. */\nexport function controlFilePath(): string {\n return join(stateDirectoryPath(), \"control.json\")\n}\n\n/** Append-only log file for the server plugin — never stdout, which corrupts the TUI. */\nexport function logFilePath(): string {\n return join(stateDirectoryPath(), \"server.log\")\n}\n","import type { KeepaliveConfig } from \"./config\"\n\n/**\n * Warming only helps on providers that do prefix caching we can refresh by\n * replaying the prefix. Supported defaults include Claude and GPT models served\n * through GitHub Copilot. Everything else is skipped.\n */\nexport function isEligibleModel(\n config: KeepaliveConfig,\n providerID: string | undefined,\n modelID: string | undefined,\n): boolean {\n if (!providerID || !modelID) return false\n const provider = providerID.toLowerCase()\n const model = modelID.toLowerCase()\n const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle))\n const modelOk = config.modelAllowlist.some((needle) => model.includes(needle))\n return providerOk && modelOk\n}\n","import type { KeepaliveConfig } from \"./config\"\nimport { readControl } from \"./control\"\nimport { isEligibleModel } from \"./model\"\nimport type { KeepaliveStore, SessionKeepalive } from \"./state\"\nimport type { Logger } from \"./logger\"\n\nconst TICK_MS = 15_000\nconst JITTER_MS = 15_000\nconst CONTROL_POLL_MS = 1_000\nconst STATUS_POLL_MS = 2_000\nconst PING_STATUS_GRACE_MS = 5_000\n\n/**\n * The keepalive engine.\n *\n * Lifecycle per session:\n * 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.\n * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.\n * 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses\n * the session's model/auth), reads `usage` to confirm a cache hit, then optionally\n * reverts the `~`/`~` turn so it does not pollute the real conversation.\n * 4. A new real user turn or the window closing stops warming.\n */\nexport class KeepaliveEngine {\n private timer: ReturnType<typeof setInterval> | undefined\n private controlTimer: ReturnType<typeof setInterval> | undefined\n private statusTimer: ReturnType<typeof setInterval> | undefined\n private controlUpdatedAt = 0\n private reconcilingStatus = false\n private enabled: boolean\n\n constructor(\n private readonly client: any,\n private readonly config: KeepaliveConfig,\n private readonly store: KeepaliveStore,\n private readonly logger: Logger,\n ) {\n const control = readControl()\n this.enabled = control?.enabled ?? config.enabled\n this.controlUpdatedAt = control?.updatedAt ?? 0\n this.store.setEnabled(this.enabled)\n }\n\n start(): void {\n if (this.timer) return\n this.timer = setInterval(() => void this.tick(), TICK_MS)\n this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS)\n this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS)\n this.store.persist()\n this.logger.info(\n `started (${this.enabled ? \"enabled\" : \"disabled\"}, ` +\n `interval=${Math.round(this.config.intervalMs / 1000)}s, ` +\n `window=${Math.round(this.config.windowMs / 60_000)}m)`,\n )\n }\n\n stop(): void {\n if (!this.timer) return\n clearInterval(this.timer)\n this.timer = undefined\n if (this.controlTimer) clearInterval(this.controlTimer)\n this.controlTimer = undefined\n if (this.statusTimer) clearInterval(this.statusTimer)\n this.statusTimer = undefined\n }\n\n /** True while a ping is in flight — used to block tool calls during a ping. */\n isWarming(sessionID: string): boolean {\n return !!this.store.get(sessionID)?.warming\n }\n\n async onEvent(event: any): Promise<void> {\n const type: string | undefined = event?.type\n if (!type) return\n\n if (type === \"session.deleted\") {\n const id = event.properties?.sessionID ?? event.properties?.info?.id\n if (id) {\n this.store.remove(id)\n this.store.persist()\n }\n return\n }\n\n if (type === \"message.updated\") {\n const info = event.properties?.info\n const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID\n if (!sessionID) return\n const session = this.store.get(sessionID)\n // Ignore everything that happens inside our own ping turn.\n if (session?.warming) return\n const eventAt = info?.time?.completed ?? info?.time?.created\n // Events are asynchronous: a completed ping may still emit message updates\n // after `warming` clears. Their timestamps predate the recorded ping.\n if (session?.lastPingAt && typeof eventAt === \"number\" && eventAt <= session.lastPingAt)\n return\n\n if (info?.role === \"user\") {\n const s = this.store.ensure(sessionID)\n s.busy = true\n s.active = false\n this.store.persist()\n return\n }\n\n if (info?.role === \"assistant\" && typeof info?.time?.completed === \"number\") {\n const s = this.store.ensure(sessionID)\n s.lastResponseAt = info.time.completed\n this.store.persist()\n }\n return\n }\n\n if (type === \"session.status\") {\n const sessionID: string | undefined = event.properties?.sessionID\n const status: string | undefined = event.properties?.status?.type\n if (!sessionID || !status) return\n\n // The ping and its optional revert emit status transitions after their\n // requests resolve. Ignore them briefly so they cannot re-arm/reset the\n // active warm window. Real user-message events still stop warming now.\n const current = this.store.get(sessionID)\n if (\n current?.warming ||\n (status !== \"idle\" &&\n current?.lastPingAt &&\n Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)\n )\n return\n\n if (status === \"idle\") {\n await this.armWindow(sessionID)\n return\n }\n\n const s = this.store.ensure(sessionID)\n s.busy = true\n s.active = false\n this.store.persist()\n return\n }\n\n // Compatibility fallback for opencode versions predating session.status.\n if (type === \"session.idle\") {\n const sessionID: string | undefined = event.properties?.sessionID\n if (!sessionID) return\n if (this.store.get(sessionID)?.warming) return // idle from our own ping\n await this.armWindow(sessionID)\n }\n }\n\n private async armWindow(sessionID: string): Promise<void> {\n const s = this.store.ensure(sessionID)\n // Duplicate idle events (including late events from a ping) must not reset the\n // current idle stretch or extend its warm window.\n if (s.active && !s.busy) return\n s.busy = false\n await this.resolveSession(s)\n\n // A new turn may have started while session metadata was being resolved.\n if (s.busy || !this.enabled || !s.eligible) {\n s.active = false\n this.store.persist()\n return\n }\n\n const now = Date.now()\n if (!s.lastResponseAt) s.lastResponseAt = now\n s.idleSince = now\n s.windowEndsAt = now + this.config.windowMs\n s.nextPingAt = now + this.config.intervalMs\n s.pingsSent = 0\n s.lastPing = undefined\n s.active = true\n this.store.persist()\n this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)\n }\n\n private async tick(): Promise<void> {\n if (!this.enabled) return\n const now = Date.now()\n for (const s of this.store.all()) {\n if (!s.eligible || !s.active || s.busy || s.warming) continue\n\n if (now >= s.windowEndsAt) {\n s.active = false\n this.store.persist()\n this.logger.dbg(\n `window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) — cache may cool`,\n )\n continue\n }\n\n if (now >= s.nextPingAt) void this.firePing(s.sessionID)\n }\n }\n\n private async firePing(sessionID: string): Promise<void> {\n const s = this.store.get(sessionID)\n if (!s || s.warming || s.busy) return\n\n s.warming = true\n // Count every request attempt. A transport/provider failure still consumed a\n // scheduled ping slot and must remain visible in the footer.\n s.pingsSent += 1\n this.store.persist()\n const startAt = Date.now()\n\n try {\n const res = await this.client.session.prompt({\n path: { id: sessionID },\n body: { parts: [{ type: \"text\", text: this.config.pingToken }] },\n })\n\n const info = res?.data?.info ?? res?.info\n const cache = info?.tokens?.cache ?? {}\n const record = {\n at: Date.now(),\n hit: Number(cache.read ?? 0) > 0,\n input: Number(info?.tokens?.input ?? 0),\n cacheRead: Number(cache.read ?? 0),\n cacheWrite: Number(cache.write ?? 0),\n output: Number(info?.tokens?.output ?? 0),\n }\n s.lastPing = record\n this.logger.dbg(\n `ping ${short(sessionID)} ${record.hit ? \"HIT\" : \"MISS\"} ` +\n `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,\n )\n\n if (this.config.revertPing) await this.revertPing(sessionID, startAt)\n } catch (err) {\n this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))\n } finally {\n s.warming = false\n s.lastPingAt = Date.now()\n s.nextPingAt = Date.now() + jitter(this.config.intervalMs)\n this.store.persist()\n }\n }\n\n /** Remove the `~` user + `~` assistant turn so it never enters real context. */\n private async revertPing(sessionID: string, startAt: number): Promise<void> {\n let messages: any[]\n try {\n const res = await this.client.session.messages({ path: { id: sessionID } })\n messages = res?.data ?? res ?? []\n } catch (err) {\n this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))\n return\n }\n\n let target: string | undefined\n for (const m of messages) {\n if (m?.info?.role !== \"user\") continue\n if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue\n const text = (m.parts ?? [])\n .filter((p: any) => p?.type === \"text\")\n .map((p: any) => p?.text ?? \"\")\n .join(\"\")\n .trim()\n if (text === this.config.pingToken) target = m.info.id\n }\n\n if (!target) {\n this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)\n return\n }\n\n try {\n await this.client.session.revert({\n path: { id: sessionID },\n body: { messageID: target },\n })\n } catch (err) {\n // BusyError (a real turn started) or similar — leave the ping visible.\n this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err))\n }\n }\n\n private async resolveSession(s: SessionKeepalive): Promise<void> {\n try {\n const res = await this.client.session.get({ path: { id: s.sessionID } })\n const info = res?.data ?? res\n if (info?.parentID && !this.config.includeChildSessions) {\n s.eligible = false\n s.modelLabel = info?.model?.id ?? \"child\"\n return\n }\n s.modelLabel = info?.model?.id ?? \"unknown\"\n s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id)\n } catch (err) {\n s.eligible = false\n this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err))\n }\n }\n\n private pollControl(): void {\n const control = readControl()\n if (!control || control.updatedAt <= this.controlUpdatedAt) return\n this.controlUpdatedAt = control.updatedAt\n if (control.enabled === this.enabled) return\n\n this.enabled = control.enabled\n this.store.setEnabled(control.enabled)\n const now = Date.now()\n for (const session of this.store.all()) {\n if (!control.enabled) {\n session.active = false\n continue\n }\n if (!session.eligible || session.busy || session.warming) continue\n if (!session.lastResponseAt) session.lastResponseAt = now\n session.idleSince = now\n session.windowEndsAt = now + this.config.windowMs\n session.nextPingAt = now + this.config.intervalMs\n session.pingsSent = 0\n session.lastPing = undefined\n session.active = true\n }\n this.store.persist()\n this.logger.info(`runtime ${control.enabled ? \"enabled\" : \"disabled\"}`)\n }\n\n /** Recover when a session.status idle event is missed by reconciling with the API. */\n private async reconcileStatus(): Promise<void> {\n if (this.reconcilingStatus) return\n const busy = this.store.all().filter((session) => session.busy && !session.warming)\n if (busy.length === 0) return\n\n this.reconcilingStatus = true\n try {\n const res = await this.client.session.status()\n const statuses = res?.data ?? res ?? {}\n for (const session of busy) {\n const status = statuses?.[session.sessionID]?.type ?? \"idle\"\n if (status === \"idle\") await this.armWindow(session.sessionID)\n }\n } catch (err) {\n this.logger.dbg(\"status reconciliation failed\", errText(err))\n } finally {\n this.reconcilingStatus = false\n }\n }\n}\n\nfunction short(sessionID: string): string {\n return sessionID.slice(-6)\n}\n\nfunction jitter(base: number): number {\n return Math.max(1_000, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS))\n}\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err)\n}\n","import { appendFileSync, mkdirSync } from \"node:fs\"\nimport { dirname } from \"node:path\"\n\nconst PREFIX = \"[cache-keepalive]\"\n\nexport class Logger {\n private readonly logPath: string\n private readonly debug: boolean\n private initialized = false\n\n constructor(debug: boolean, logPath: string) {\n this.debug = debug\n this.logPath = logPath\n }\n\n info(message: string, data?: unknown) {\n this.write(\"INFO\", message, data)\n }\n\n warn(message: string, data?: unknown) {\n this.write(\"WARN\", message, data)\n }\n\n error(message: string, data?: unknown) {\n this.write(\"ERROR\", message, data)\n }\n\n dbg(message: string, data?: unknown) {\n if (this.debug) this.write(\"DEBUG\", message, data)\n }\n\n private write(level: string, message: string, data?: unknown): void {\n const ts = new Date().toISOString()\n const extra =\n data !== undefined ? ` ${typeof data === \"string\" ? data : JSON.stringify(data)}` : \"\"\n const line = `${ts} ${level} ${PREFIX} ${message}${extra}\\n`\n try {\n if (!this.initialized) {\n mkdirSync(dirname(this.logPath), { recursive: true })\n this.initialized = true\n }\n appendFileSync(this.logPath, line, \"utf8\")\n } catch {\n // Best-effort logging — never crash the plugin.\n }\n }\n}\n","import { mkdir, rename, writeFile } from \"node:fs/promises\"\nimport { dirname } from \"node:path\"\nimport type { KeepaliveConfig } from \"./config\"\nimport { stateFilePath } from \"./paths\"\n\nexport type PingRecord = {\n at: number\n hit: boolean\n input: number\n cacheRead: number\n cacheWrite: number\n output: number\n}\n\n/** Live per-session bookkeeping held by the server plugin. */\nexport type SessionKeepalive = {\n sessionID: string\n /** Whether the session's provider/model pair supports cache warming. */\n eligible: boolean\n modelLabel?: string\n /** Epoch ms of the last real assistant response (excludes ping replies). */\n lastResponseAt: number\n /** Epoch ms the current idle stretch began. */\n idleSince: number\n /** Epoch ms after which warming stops and the cache is allowed to go cold. */\n windowEndsAt: number\n /** Epoch ms the next ping is due. */\n nextPingAt: number\n /** Number of scheduled ping requests attempted during this warm window. */\n pingsSent: number\n /** A real turn is currently running. */\n busy: boolean\n /** A keepalive ping is currently in flight. */\n warming: boolean\n /** Within the warm window and actively scheduling pings. */\n active: boolean\n /** Epoch ms of the last ping; retained only to ignore its late events. */\n lastPingAt?: number\n lastPing?: PingRecord\n}\n\n/** Lean projection persisted to disk for the TUI reader. */\nexport type PersistedSession = {\n eligible: boolean\n modelLabel?: string\n lastResponseAt: number\n idleSince: number\n windowEndsAt: number\n nextPingAt: number\n intervalMs: number\n pingsSent: number\n busy: boolean\n warming: boolean\n active: boolean\n lastPing?: PingRecord\n}\n\nexport type PersistedState = {\n version: 1\n updatedAt: number\n enabled: boolean\n intervalMs: number\n windowMs: number\n sessions: Record<string, PersistedSession>\n}\n\nexport class KeepaliveStore {\n private readonly sessions = new Map<string, SessionKeepalive>()\n private writeQueue: Promise<void> = Promise.resolve()\n private enabled: boolean\n\n constructor(\n private readonly config: KeepaliveConfig,\n private readonly directory: string,\n ) {\n this.enabled = config.enabled\n }\n\n setEnabled(enabled: boolean): void {\n this.enabled = enabled\n }\n\n get(sessionID: string): SessionKeepalive | undefined {\n return this.sessions.get(sessionID)\n }\n\n ensure(sessionID: string): SessionKeepalive {\n const existing = this.sessions.get(sessionID)\n if (existing) return existing\n const created: SessionKeepalive = {\n sessionID,\n eligible: false,\n lastResponseAt: 0,\n idleSince: 0,\n windowEndsAt: 0,\n nextPingAt: 0,\n pingsSent: 0,\n busy: false,\n warming: false,\n active: false,\n }\n this.sessions.set(sessionID, created)\n return created\n }\n\n remove(sessionID: string): void {\n this.sessions.delete(sessionID)\n }\n\n all(): SessionKeepalive[] {\n return [...this.sessions.values()]\n }\n\n /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */\n persist(): void {\n const snapshot: PersistedState = {\n version: 1,\n updatedAt: Date.now(),\n enabled: this.enabled,\n intervalMs: this.config.intervalMs,\n windowMs: this.config.windowMs,\n sessions: Object.fromEntries(\n this.all().map((s) => [\n s.sessionID,\n {\n eligible: s.eligible,\n modelLabel: s.modelLabel,\n lastResponseAt: s.lastResponseAt,\n idleSince: s.idleSince,\n windowEndsAt: s.windowEndsAt,\n nextPingAt: s.nextPingAt,\n intervalMs: this.config.intervalMs,\n pingsSent: s.pingsSent,\n busy: s.busy,\n warming: s.warming,\n active: s.active,\n lastPing: s.lastPing,\n } satisfies PersistedSession,\n ]),\n ),\n }\n\n const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))\n this.writeQueue = this.writeQueue.then(write, write)\n }\n}\n\nasync function atomicWrite(path: string, contents: string): Promise<void> {\n try {\n const tmp = `${path}.${process.pid}.${Date.now()}.tmp`\n await mkdir(dirname(path), { recursive: true })\n await writeFile(tmp, `${contents}\\n`, \"utf8\")\n await rename(tmp, path)\n } catch {\n // Persistence is best-effort; the TUI readout simply falls behind.\n }\n}\n","/**\n * Stable keepalive instruction appended to the system prompt.\n *\n * IMPORTANT: this text must be byte-stable and applied to *every* request for an\n * eligible model (real turns and pings alike). If it were only added on pings, the\n * system prefix would differ from real turns and the ping would miss the cache.\n * Adding it consistently keeps the cached prefix identical and lets the model\n * answer a ping with a single token instead of a paragraph or a tool call.\n */\nexport function keepaliveInstruction(pingToken: string): string {\n return [\n \"<cache_keepalive>\",\n `A user message consisting of exactly the single token \"${pingToken}\" is an`,\n \"automated cache-keepalive ping, not a real request. When you receive it,\",\n `reply with exactly \"${pingToken}\" and nothing else. Do not call any tools,`,\n \"do not explain, and do not take any action.\",\n \"</cache_keepalive>\",\n ].join(\"\\n\")\n}\n","import type { Plugin } from \"@opencode-ai/plugin\"\nimport { configureClientAuth, isSecureMode } from \"./lib/auth\"\nimport { getConfig } from \"./lib/config\"\nimport { KeepaliveEngine } from \"./lib/keepalive\"\nimport { Logger } from \"./lib/logger\"\nimport { isEligibleModel } from \"./lib/model\"\nimport { logFilePath } from \"./lib/paths\"\nimport { KeepaliveStore } from \"./lib/state\"\nimport { keepaliveInstruction } from \"./lib/system\"\n\ndeclare const __KEEPALIVE_VERSION__: string\n\nconst server: Plugin = async (ctx, options) => {\n const config = getConfig(options as Record<string, unknown> | undefined)\n const logger = new Logger(config.debug, logFilePath())\n\n if (!config.enabled) {\n logger.info(\"disabled via config\")\n return {}\n }\n\n if (isSecureMode()) configureClientAuth(ctx.client)\n\n const store = new KeepaliveStore(config, ctx.directory)\n const engine = new KeepaliveEngine(ctx.client, config, store, logger)\n engine.start()\n\n const version = typeof __KEEPALIVE_VERSION__ !== \"undefined\" ? __KEEPALIVE_VERSION__ : \"dev\"\n logger.info(`v${version} ready`)\n\n const instruction = keepaliveInstruction(config.pingToken)\n\n return {\n event: async ({ event }) => {\n await engine.onEvent(event)\n },\n\n // Keep the system prefix identical between real turns and pings, and make\n // the model answer a ping with a single token.\n \"experimental.chat.system.transform\": async (input, output) => {\n if (!config.injectSystemInstruction) return\n const model = input.model as { providerID?: string; id?: string; modelID?: string }\n if (!isEligibleModel(config, model?.providerID, model?.id ?? model?.modelID)) return\n output.system.push(instruction)\n },\n\n // Backstop: if the model ignores the instruction and tries a tool during a\n // ping, block it — the ping turn is going to be reverted anyway.\n \"tool.execute.before\": async (input) => {\n if (engine.isWarming(input.sessionID)) {\n throw new Error(\"cache-keepalive: tools are disabled during a keepalive ping\")\n }\n },\n\n dispose: async () => {\n engine.stop()\n },\n }\n}\n\nexport default server\n"],"mappings":"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","names":["dirname","mkdir","rename","writeFile","dirname","mkdir","dirname","writeFile","rename"]}
1
+ {"version":3,"sources":["../lib/auth.ts","../lib/config.ts","../lib/control.ts","../lib/paths.ts","../lib/model.ts","../lib/keepalive.ts","../lib/logger.ts","../lib/state.ts","../lib/system.ts","../index.ts"],"sourcesContent":["/**\n * When opencode runs with `OPENCODE_SERVER_PASSWORD`, the local HTTP API requires\n * Basic auth. The SDK client handed to plugins does not carry it automatically,\n * so we attach an interceptor. No-op when the server is not password protected.\n *\n * Same approach as opencode-dynamic-context-pruning's auth helper.\n */\nexport function isSecureMode(): boolean {\n return !!process.env.OPENCODE_SERVER_PASSWORD\n}\n\nexport function getAuthorizationHeader(): string | undefined {\n const password = process.env.OPENCODE_SERVER_PASSWORD\n if (!password) return undefined\n const username = process.env.OPENCODE_SERVER_USERNAME ?? \"opencode\"\n const credentials = Buffer.from(`${username}:${password}`).toString(\"base64\")\n return `Basic ${credentials}`\n}\n\nexport function configureClientAuth(client: any): void {\n const header = getAuthorizationHeader()\n if (!header) return\n\n const inner = client?._client ?? client?.client\n if (!inner?.interceptors?.request) return\n\n inner.interceptors.request.use((request: Request) => {\n if (!request.headers.has(\"Authorization\")) {\n request.headers.set(\"Authorization\", header)\n }\n return request\n })\n}\n","export type KeepaliveConfig = {\n /** Master switch. When false the plugin registers no timers or hooks. */\n enabled: boolean\n /** Milliseconds between pings. Must stay under the shortest supported provider TTL. */\n intervalMs: number\n /**\n * How long to keep warming after the last real response before giving up and\n * letting the cache go cold. The default follows the Copilot Claude\n * write/read break-even; providers with automatic caching may be cheaper.\n */\n windowMs: number\n /**\n * Append a stable keepalive instruction to the system prompt so the model\n * answers a `~` ping with a single `~` token and never calls tools. The\n * instruction is added to real turns too, so the cached prefix stays identical.\n */\n injectSystemInstruction: boolean\n /** The single character/token used as the keepalive suffix. */\n pingToken: string\n /** Remove synthetic ping turns after measuring their cache usage. */\n revertPing: boolean\n /** Warm subagent/child sessions too. Off by default to avoid wasted pings. */\n includeChildSessions: boolean\n debug: boolean\n /** providerID substrings eligible for warming (cache-controlled providers). */\n providerAllowlist: string[]\n /** modelID substrings eligible for warming. */\n modelAllowlist: string[]\n}\n\nconst DEFAULTS: KeepaliveConfig = {\n enabled: true,\n intervalMs: 270_000, // 4.5 min — below the shortest supported cache TTL\n windowMs: 3_300_000, // 55 min — the Copilot Claude write/read break-even\n injectSystemInstruction: true,\n pingToken: \"~\",\n revertPing: true,\n includeChildSessions: false,\n debug: false,\n providerAllowlist: [\"copilot\"],\n modelAllowlist: [\"claude\", \"anthropic\", \"sonnet\", \"opus\", \"haiku\", \"gpt\"],\n}\n\nexport function getConfig(options: Record<string, unknown> | undefined): KeepaliveConfig {\n const o = options ?? {}\n const env = process.env\n\n return {\n enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== \"false\" && DEFAULTS.enabled),\n intervalMs: durationOpt(\n o.intervalMs,\n o.intervalSeconds,\n numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs),\n ),\n windowMs: durationOpt(\n o.windowMs,\n o.windowMinutes ? Number(o.windowMinutes) * 60 : undefined,\n numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs),\n ),\n injectSystemInstruction: boolOpt(\n o.injectSystemInstruction,\n DEFAULTS.injectSystemInstruction,\n ),\n pingToken:\n typeof o.pingToken === \"string\" && o.pingToken.length > 0\n ? o.pingToken\n : DEFAULTS.pingToken,\n revertPing: boolOpt(\n o.revertPing,\n env.OPENCODE_KEEPALIVE_REVERT_PING !== \"false\" && DEFAULTS.revertPing,\n ),\n includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),\n debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === \"true\"),\n providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),\n modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist),\n }\n}\n\nfunction boolOpt(value: unknown, fallback: boolean): boolean {\n return typeof value === \"boolean\" ? value : fallback\n}\n\nfunction listOpt(value: unknown, fallback: string[]): string[] {\n if (!Array.isArray(value)) return fallback\n const filtered = value.filter((item): item is string => typeof item === \"string\")\n return filtered.length > 0 ? filtered : fallback\n}\n\n/** Accept an explicit `*Ms` option, a `*Seconds` option, or fall back. */\nfunction durationOpt(ms: unknown, seconds: unknown, fallback: number): number {\n if (typeof ms === \"number\" && Number.isFinite(ms) && ms > 0) return ms\n if (typeof seconds === \"number\" && Number.isFinite(seconds) && seconds > 0)\n return seconds * 1000\n return fallback\n}\n\nfunction numEnv(raw: string | undefined, fallback: number): number {\n if (!raw) return fallback\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? value : fallback\n}\n","import { readFileSync } from \"node:fs\"\nimport { mkdir, rename, writeFile } from \"node:fs/promises\"\nimport { dirname } from \"node:path\"\nimport { controlFilePath } from \"./paths\"\n\nexport type KeepaliveControl = {\n version: 1\n enabled: boolean\n updatedAt: number\n}\n\nexport function readControl(directory: string): KeepaliveControl | undefined {\n try {\n const value = JSON.parse(\n readFileSync(controlFilePath(directory), \"utf8\"),\n ) as Partial<KeepaliveControl>\n if (value.version !== 1 || typeof value.enabled !== \"boolean\") return undefined\n if (typeof value.updatedAt !== \"number\") return undefined\n return value as KeepaliveControl\n } catch {\n return undefined\n }\n}\n\nexport async function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl> {\n const value: KeepaliveControl = {\n version: 1,\n enabled,\n updatedAt: Date.now(),\n }\n const path = controlFilePath(directory)\n const tmp = `${path}.${process.pid}.${value.updatedAt}.tmp`\n await mkdir(dirname(path), { recursive: true })\n await writeFile(tmp, `${JSON.stringify(value)}\\n`, \"utf8\")\n await rename(tmp, path)\n return value\n}\n","import { createHash } from \"node:crypto\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\n/**\n * Absolute path to the JSON state file shared between the server plugin (writer)\n * and the TUI plugin (reader). Both processes run on the same machine, so a file\n * under opencode's plugin storage dir is the simplest cross-process channel.\n *\n * Mirrors the storage layout used by other opencode plugins:\n * <XDG_DATA_HOME | ~/.local/share>/opencode/storage/plugin/keepalive/state.json\n */\nexport function stateFilePath(directory: string): string {\n const key = createHash(\"sha256\").update(directory).digest(\"hex\").slice(0, 16)\n return join(stateDirectoryPath(), `state-${key}.json`)\n}\n\nexport function stateDirectoryPath(): string {\n return join(\n process.env.XDG_DATA_HOME ?? join(homedir(), \".local\", \"share\"),\n \"opencode\",\n \"storage\",\n \"plugin\",\n \"keepalive\",\n )\n}\n\n/**\n * Runtime on/off control written by the TUI and read by the server plugin.\n * Scoped per directory (like the state file) so toggling keepalive in one\n * project does not affect every other open opencode session.\n */\nexport function controlFilePath(directory: string): string {\n const key = createHash(\"sha256\").update(directory).digest(\"hex\").slice(0, 16)\n return join(stateDirectoryPath(), `control-${key}.json`)\n}\n\n/** Append-only log file for the server plugin — never stdout, which corrupts the TUI. */\nexport function logFilePath(): string {\n return join(stateDirectoryPath(), \"server.log\")\n}\n","import type { KeepaliveConfig } from \"./config\"\n\n/**\n * Warming only helps on providers that do prefix caching we can refresh by\n * replaying the prefix. Supported defaults include Claude and GPT models served\n * through GitHub Copilot. Everything else is skipped.\n */\nexport function isEligibleModel(\n config: KeepaliveConfig,\n providerID: string | undefined,\n modelID: string | undefined,\n): boolean {\n if (!providerID || !modelID) return false\n const provider = providerID.toLowerCase()\n const model = modelID.toLowerCase()\n const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle))\n const modelOk = config.modelAllowlist.some((needle) => model.includes(needle))\n return providerOk && modelOk\n}\n","import type { KeepaliveConfig } from \"./config\"\nimport { readControl } from \"./control\"\nimport { isEligibleModel } from \"./model\"\nimport type { KeepaliveStore, SessionKeepalive } from \"./state\"\nimport type { Logger } from \"./logger\"\n\nconst TICK_MS = 15_000\nconst JITTER_MS = 15_000\nconst CONTROL_POLL_MS = 1_000\nconst STATUS_POLL_MS = 2_000\nconst PING_STATUS_GRACE_MS = 5_000\n/** Backstop: clear a stuck `busy` flag after this long without an idle event. */\nconst BUSY_TIMEOUT_MS = 300_000\n\n/**\n * The keepalive engine.\n *\n * Lifecycle per session:\n * 1. A real turn finishes -> `session.idle` -> arm a warm window and schedule pings.\n * 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.\n * 3. `firePing` sends a `~` prompt through opencode (exact cached prefix, reuses\n * the session's model/auth), reads `usage` to confirm a cache hit, then optionally\n * reverts the `~`/`~` turn so it does not pollute the real conversation.\n * 4. A new real user turn or the window closing stops warming.\n */\nexport class KeepaliveEngine {\n private timer: ReturnType<typeof setInterval> | undefined\n private controlTimer: ReturnType<typeof setInterval> | undefined\n private statusTimer: ReturnType<typeof setInterval> | undefined\n private controlUpdatedAt = 0\n private reconcilingStatus = false\n private enabled: boolean\n\n constructor(\n private readonly client: any,\n private readonly config: KeepaliveConfig,\n private readonly store: KeepaliveStore,\n private readonly logger: Logger,\n private readonly directory: string,\n ) {\n const control = readControl(directory)\n this.enabled = control?.enabled ?? config.enabled\n this.controlUpdatedAt = control?.updatedAt ?? 0\n this.store.setEnabled(this.enabled)\n }\n\n start(): void {\n if (this.timer) return\n this.timer = setInterval(() => void this.tick(), TICK_MS)\n this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS)\n this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS)\n this.store.persist()\n this.logger.info(\n `started (${this.enabled ? \"enabled\" : \"disabled\"}, ` +\n `interval=${Math.round(this.config.intervalMs / 1000)}s, ` +\n `window=${Math.round(this.config.windowMs / 60_000)}m)`,\n )\n }\n\n stop(): void {\n if (!this.timer) return\n clearInterval(this.timer)\n this.timer = undefined\n if (this.controlTimer) clearInterval(this.controlTimer)\n this.controlTimer = undefined\n if (this.statusTimer) clearInterval(this.statusTimer)\n this.statusTimer = undefined\n }\n\n /** True while a ping is in flight — used to block tool calls during a ping. */\n isWarming(sessionID: string): boolean {\n return !!this.store.get(sessionID)?.warming\n }\n\n async onEvent(event: any): Promise<void> {\n const type: string | undefined = event?.type\n if (!type) return\n\n if (type === \"session.deleted\") {\n const id = event.properties?.sessionID ?? event.properties?.info?.id\n if (id) {\n this.store.remove(id)\n this.store.persist()\n }\n return\n }\n\n if (type === \"message.updated\") {\n const info = event.properties?.info\n const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID\n if (!sessionID) return\n const session = this.store.get(sessionID)\n // Ignore everything that happens inside our own ping turn.\n if (session?.warming) return\n const eventAt = info?.time?.completed ?? info?.time?.created\n // Events are asynchronous: a completed ping may still emit message updates\n // after `warming` clears. Their timestamps predate the recorded ping.\n if (session?.lastPingAt && typeof eventAt === \"number\" && eventAt <= session.lastPingAt)\n return\n\n if (info?.role === \"user\") {\n const s = this.store.ensure(sessionID)\n s.busy = true\n s.busySince = Date.now()\n s.active = false\n this.store.persist()\n return\n }\n\n if (info?.role === \"assistant\" && typeof info?.time?.completed === \"number\") {\n const s = this.store.ensure(sessionID)\n s.lastResponseAt = info.time.completed\n this.store.persist()\n }\n return\n }\n\n if (type === \"session.status\") {\n const sessionID: string | undefined = event.properties?.sessionID\n const status: string | undefined = event.properties?.status?.type\n if (!sessionID || !status) return\n\n // The ping and its optional revert emit status transitions after their\n // requests resolve. Ignore them briefly so they cannot re-arm/reset the\n // active warm window. Real user-message events still stop warming now.\n const current = this.store.get(sessionID)\n if (\n current?.warming ||\n (status !== \"idle\" &&\n current?.lastPingAt &&\n Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)\n )\n return\n\n if (status === \"idle\") {\n await this.armWindow(sessionID)\n return\n }\n\n const s = this.store.ensure(sessionID)\n s.busy = true\n s.busySince = Date.now()\n s.active = false\n this.store.persist()\n return\n }\n\n // Compatibility fallback for opencode versions predating session.status.\n if (type === \"session.idle\") {\n const sessionID: string | undefined = event.properties?.sessionID\n if (!sessionID) return\n if (this.store.get(sessionID)?.warming) return // idle from our own ping\n await this.armWindow(sessionID)\n }\n }\n\n private async armWindow(sessionID: string): Promise<void> {\n const s = this.store.ensure(sessionID)\n // Duplicate idle events (including late events from a ping) must not reset the\n // current idle stretch or extend its warm window.\n if (s.active && !s.busy) return\n // Serialize: only one armWindow may resolve metadata at a time per session.\n // This prevents the session.status + session.idle double-event race from\n // producing two concurrent resolveSession calls that can clobber each other.\n if (s.arming) return\n s.arming = true\n try {\n s.busy = false\n s.busySince = undefined\n await this.resolveSession(s)\n\n // A new turn may have started while session metadata was being resolved.\n if (s.busy || !this.enabled || !s.eligible) {\n s.active = false\n this.store.persist()\n return\n }\n\n const now = Date.now()\n if (!s.lastResponseAt) s.lastResponseAt = now\n s.idleSince = now\n s.windowEndsAt = now + this.config.windowMs\n s.nextPingAt = now + this.config.intervalMs\n s.pingsSent = 0\n s.lastPing = undefined\n s.active = true\n this.store.persist()\n this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)\n } finally {\n s.arming = false\n }\n }\n\n private async tick(): Promise<void> {\n if (!this.enabled) return\n const now = Date.now()\n for (const s of this.store.all()) {\n // Backstop: if busy is stuck (missed idle event + reconcile failure),\n // clear it after BUSY_TIMEOUT_MS so the session can re-arm.\n if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {\n this.logger.dbg(`busy timeout ${short(s.sessionID)} — clearing stuck flag`)\n s.busy = false\n s.busySince = undefined\n void this.armWindow(s.sessionID)\n continue\n }\n\n if (!s.eligible || !s.active || s.busy || s.warming) continue\n\n if (now >= s.windowEndsAt) {\n s.active = false\n this.store.persist()\n this.logger.dbg(\n `window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) — cache may cool`,\n )\n continue\n }\n\n if (now >= s.nextPingAt) void this.firePing(s.sessionID)\n }\n }\n\n private async firePing(sessionID: string): Promise<void> {\n const s = this.store.get(sessionID)\n if (!s || s.warming || s.busy) return\n\n s.warming = true\n // Count every request attempt. A transport/provider failure still consumed a\n // scheduled ping slot and must remain visible in the footer.\n s.pingsSent += 1\n this.store.persist()\n const startAt = Date.now()\n\n try {\n const res = await this.client.session.prompt({\n path: { id: sessionID },\n body: { parts: [{ type: \"text\", text: this.config.pingToken }] },\n })\n\n const info = res?.data?.info ?? res?.info\n const cache = info?.tokens?.cache ?? {}\n const record = {\n at: Date.now(),\n hit: Number(cache.read ?? 0) > 0,\n input: Number(info?.tokens?.input ?? 0),\n cacheRead: Number(cache.read ?? 0),\n cacheWrite: Number(cache.write ?? 0),\n output: Number(info?.tokens?.output ?? 0),\n }\n s.lastPing = record\n this.logger.dbg(\n `ping ${short(sessionID)} ${record.hit ? \"HIT\" : \"MISS\"} ` +\n `in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,\n )\n\n if (this.config.revertPing) await this.revertPing(sessionID, startAt)\n } catch (err) {\n this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))\n } finally {\n s.warming = false\n s.lastPingAt = Date.now()\n // Schedule relative to the intended time so ping duration doesn't\n // compound drift. Floor at now+1s to avoid immediate re-fire if the\n // ping took longer than the interval.\n s.nextPingAt = Math.max(\n Date.now() + 1_000,\n s.nextPingAt + jitter(this.config.intervalMs),\n )\n this.store.persist()\n }\n }\n\n /** Remove the `~` user + `~` assistant turn so it never enters real context. */\n private async revertPing(sessionID: string, startAt: number): Promise<void> {\n let messages: any[]\n try {\n const res = await this.client.session.messages({ path: { id: sessionID } })\n messages = res?.data ?? res ?? []\n } catch (err) {\n this.logger.warn(`ping ${short(sessionID)} revert: list failed`, errText(err))\n return\n }\n\n let target: string | undefined\n for (const m of messages) {\n if (m?.info?.role !== \"user\") continue\n if (Number(m.info?.time?.created ?? 0) < startAt - 2_000) continue\n const text = (m.parts ?? [])\n .filter((p: any) => p?.type === \"text\")\n .map((p: any) => p?.text ?? \"\")\n .join(\"\")\n .trim()\n if (text === this.config.pingToken) target = m.info.id\n }\n\n if (!target) {\n this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)\n return\n }\n\n try {\n await this.client.session.revert({\n path: { id: sessionID },\n body: { messageID: target },\n })\n } catch (err) {\n // BusyError (a real turn started) or similar — leave the ping visible.\n this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err))\n }\n }\n\n private async resolveSession(s: SessionKeepalive): Promise<void> {\n try {\n const res = await this.client.session.get({ path: { id: s.sessionID } })\n const info = res?.data ?? res\n if (info?.parentID && !this.config.includeChildSessions) {\n s.eligible = false\n s.modelLabel = info?.model?.id ?? \"child\"\n return\n }\n s.modelLabel = info?.model?.id ?? \"unknown\"\n s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id)\n } catch (err) {\n s.eligible = false\n this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err))\n }\n }\n\n private pollControl(): void {\n const control = readControl(this.directory)\n if (!control || control.updatedAt <= this.controlUpdatedAt) return\n this.controlUpdatedAt = control.updatedAt\n if (control.enabled === this.enabled) return\n\n this.enabled = control.enabled\n this.store.setEnabled(control.enabled)\n const now = Date.now()\n for (const session of this.store.all()) {\n if (!control.enabled) {\n session.active = false\n continue\n }\n // Only re-arm sessions that have had a real turn (cache to warm) and\n // are not currently occupied.\n if (!session.eligible || session.busy || session.warming) continue\n if (!session.lastResponseAt) continue\n session.idleSince = now\n session.windowEndsAt = now + this.config.windowMs\n session.nextPingAt = now + this.config.intervalMs\n session.pingsSent = 0\n session.lastPing = undefined\n session.active = true\n }\n this.store.persist()\n this.logger.info(`runtime ${control.enabled ? \"enabled\" : \"disabled\"}`)\n }\n\n /** Recover when a session.status idle event is missed by reconciling with the API. */\n private async reconcileStatus(): Promise<void> {\n if (this.reconcilingStatus) return\n const busy = this.store.all().filter((session) => session.busy && !session.warming)\n if (busy.length === 0) return\n\n this.reconcilingStatus = true\n try {\n const res = await this.client.session.status()\n const statuses = res?.data ?? res ?? {}\n for (const session of busy) {\n const status = statuses?.[session.sessionID]?.type ?? \"idle\"\n if (status === \"idle\") await this.armWindow(session.sessionID)\n }\n } catch (err) {\n this.logger.dbg(\"status reconciliation failed\", errText(err))\n } finally {\n this.reconcilingStatus = false\n }\n }\n}\n\nfunction short(sessionID: string): string {\n return sessionID.slice(-6)\n}\n\nfunction jitter(base: number): number {\n return Math.max(1_000, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS))\n}\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err)\n}\n","import { appendFileSync, mkdirSync } from \"node:fs\"\nimport { dirname } from \"node:path\"\n\nconst PREFIX = \"[cache-keepalive]\"\n\nexport class Logger {\n private readonly logPath: string\n private readonly debug: boolean\n private initialized = false\n\n constructor(debug: boolean, logPath: string) {\n this.debug = debug\n this.logPath = logPath\n }\n\n info(message: string, data?: unknown) {\n this.write(\"INFO\", message, data)\n }\n\n warn(message: string, data?: unknown) {\n this.write(\"WARN\", message, data)\n }\n\n error(message: string, data?: unknown) {\n this.write(\"ERROR\", message, data)\n }\n\n dbg(message: string, data?: unknown) {\n if (this.debug) this.write(\"DEBUG\", message, data)\n }\n\n private write(level: string, message: string, data?: unknown): void {\n const ts = new Date().toISOString()\n const extra =\n data !== undefined ? ` ${typeof data === \"string\" ? data : JSON.stringify(data)}` : \"\"\n const line = `${ts} ${level} ${PREFIX} ${message}${extra}\\n`\n try {\n if (!this.initialized) {\n mkdirSync(dirname(this.logPath), { recursive: true })\n this.initialized = true\n }\n appendFileSync(this.logPath, line, \"utf8\")\n } catch {\n // Best-effort logging — never crash the plugin.\n }\n }\n}\n","import { mkdir, rename, writeFile } from \"node:fs/promises\"\nimport { dirname } from \"node:path\"\nimport type { KeepaliveConfig } from \"./config\"\nimport { stateFilePath } from \"./paths\"\n\nexport type PingRecord = {\n at: number\n hit: boolean\n input: number\n cacheRead: number\n cacheWrite: number\n output: number\n}\n\n/** Live per-session bookkeeping held by the server plugin. */\nexport type SessionKeepalive = {\n sessionID: string\n /** Whether the session's provider/model pair supports cache warming. */\n eligible: boolean\n modelLabel?: string\n /** Epoch ms of the last real assistant response (excludes ping replies). */\n lastResponseAt: number\n /** Epoch ms the current idle stretch began. */\n idleSince: number\n /** Epoch ms after which warming stops and the cache is allowed to go cold. */\n windowEndsAt: number\n /** Epoch ms the next ping is due. */\n nextPingAt: number\n /** Number of scheduled ping requests attempted during this warm window. */\n pingsSent: number\n /** A real turn is currently running. */\n busy: boolean\n /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */\n busySince?: number\n /** A keepalive ping is currently in flight. */\n warming: boolean\n /** Within the warm window and actively scheduling pings. */\n active: boolean\n /** True while `armWindow` is resolving session metadata (async guard). */\n arming?: boolean\n /** Epoch ms of the last ping; retained only to ignore its late events. */\n lastPingAt?: number\n lastPing?: PingRecord\n}\n\n/** Lean projection persisted to disk for the TUI reader. */\nexport type PersistedSession = {\n eligible: boolean\n modelLabel?: string\n lastResponseAt: number\n idleSince: number\n windowEndsAt: number\n nextPingAt: number\n intervalMs: number\n pingsSent: number\n busy: boolean\n warming: boolean\n active: boolean\n lastPing?: PingRecord\n}\n\nexport type PersistedState = {\n version: 1\n updatedAt: number\n enabled: boolean\n intervalMs: number\n windowMs: number\n sessions: Record<string, PersistedSession>\n}\n\nexport class KeepaliveStore {\n private readonly sessions = new Map<string, SessionKeepalive>()\n private writeQueue: Promise<void> = Promise.resolve()\n private enabled: boolean\n\n constructor(\n private readonly config: KeepaliveConfig,\n private readonly directory: string,\n ) {\n this.enabled = config.enabled\n }\n\n setEnabled(enabled: boolean): void {\n this.enabled = enabled\n }\n\n get(sessionID: string): SessionKeepalive | undefined {\n return this.sessions.get(sessionID)\n }\n\n ensure(sessionID: string): SessionKeepalive {\n const existing = this.sessions.get(sessionID)\n if (existing) return existing\n const created: SessionKeepalive = {\n sessionID,\n eligible: false,\n lastResponseAt: 0,\n idleSince: 0,\n windowEndsAt: 0,\n nextPingAt: 0,\n pingsSent: 0,\n busy: false,\n warming: false,\n active: false,\n }\n this.sessions.set(sessionID, created)\n return created\n }\n\n remove(sessionID: string): void {\n this.sessions.delete(sessionID)\n }\n\n all(): SessionKeepalive[] {\n return [...this.sessions.values()]\n }\n\n /** Atomically persist a lean snapshot for the TUI. Never throws to callers. */\n persist(): void {\n const snapshot: PersistedState = {\n version: 1,\n updatedAt: Date.now(),\n enabled: this.enabled,\n intervalMs: this.config.intervalMs,\n windowMs: this.config.windowMs,\n sessions: Object.fromEntries(\n this.all().map((s) => [\n s.sessionID,\n {\n eligible: s.eligible,\n modelLabel: s.modelLabel,\n lastResponseAt: s.lastResponseAt,\n idleSince: s.idleSince,\n windowEndsAt: s.windowEndsAt,\n nextPingAt: s.nextPingAt,\n intervalMs: this.config.intervalMs,\n pingsSent: s.pingsSent,\n busy: s.busy,\n warming: s.warming,\n active: s.active,\n lastPing: s.lastPing,\n } satisfies PersistedSession,\n ]),\n ),\n }\n\n const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))\n this.writeQueue = this.writeQueue.then(write, write)\n }\n}\n\nasync function atomicWrite(path: string, contents: string): Promise<void> {\n try {\n const tmp = `${path}.${process.pid}.${Date.now()}.tmp`\n await mkdir(dirname(path), { recursive: true })\n await writeFile(tmp, `${contents}\\n`, \"utf8\")\n await rename(tmp, path)\n } catch {\n // Persistence is best-effort; the TUI readout simply falls behind.\n }\n}\n","/**\n * Stable keepalive instruction appended to the system prompt.\n *\n * IMPORTANT: this text must be byte-stable and applied to *every* request for an\n * eligible model (real turns and pings alike). If it were only added on pings, the\n * system prefix would differ from real turns and the ping would miss the cache.\n * Adding it consistently keeps the cached prefix identical and lets the model\n * answer a ping with a single token instead of a paragraph or a tool call.\n */\nexport function keepaliveInstruction(pingToken: string): string {\n return [\n \"<cache_keepalive>\",\n `A user message consisting of exactly the single token \"${pingToken}\" is an`,\n \"automated cache-keepalive ping, not a real request. When you receive it,\",\n `reply with exactly \"${pingToken}\" and nothing else. Do not call any tools,`,\n \"do not explain, and do not take any action.\",\n \"</cache_keepalive>\",\n ].join(\"\\n\")\n}\n","import type { Plugin } from \"@opencode-ai/plugin\"\nimport { configureClientAuth, isSecureMode } from \"./lib/auth\"\nimport { getConfig } from \"./lib/config\"\nimport { KeepaliveEngine } from \"./lib/keepalive\"\nimport { Logger } from \"./lib/logger\"\nimport { isEligibleModel } from \"./lib/model\"\nimport { logFilePath } from \"./lib/paths\"\nimport { KeepaliveStore } from \"./lib/state\"\nimport { keepaliveInstruction } from \"./lib/system\"\n\ndeclare const __KEEPALIVE_VERSION__: string\n\nconst server: Plugin = async (ctx, options) => {\n const config = getConfig(options as Record<string, unknown> | undefined)\n const logger = new Logger(config.debug, logFilePath())\n\n if (!config.enabled) {\n logger.info(\"disabled via config\")\n return {}\n }\n\n if (isSecureMode()) configureClientAuth(ctx.client)\n\n const store = new KeepaliveStore(config, ctx.directory)\n const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory)\n engine.start()\n\n const version = typeof __KEEPALIVE_VERSION__ !== \"undefined\" ? __KEEPALIVE_VERSION__ : \"dev\"\n logger.info(`v${version} ready`)\n\n const instruction = keepaliveInstruction(config.pingToken)\n\n return {\n event: async ({ event }) => {\n await engine.onEvent(event)\n },\n\n // Keep the system prefix identical between real turns and pings, and make\n // the model answer a ping with a single token.\n \"experimental.chat.system.transform\": async (input, output) => {\n if (!config.injectSystemInstruction) return\n const model = input.model as { providerID?: string; id?: string; modelID?: string }\n if (!isEligibleModel(config, model?.providerID, model?.id ?? model?.modelID)) return\n output.system.push(instruction)\n },\n\n // Backstop: if the model ignores the instruction and tries a tool during a\n // ping, block it — the ping turn is going to be reverted anyway.\n \"tool.execute.before\": async (input) => {\n if (engine.isWarming(input.sessionID)) {\n throw new Error(\"cache-keepalive: tools are disabled during a keepalive ping\")\n }\n },\n\n dispose: async () => {\n engine.stop()\n },\n }\n}\n\nexport default server\n"],"mappings":"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","names":["dirname","mkdir","rename","writeFile","dirname","mkdir","dirname","writeFile","rename"]}
@@ -3,6 +3,6 @@ export type KeepaliveControl = {
3
3
  enabled: boolean;
4
4
  updatedAt: number;
5
5
  };
6
- export declare function readControl(): KeepaliveControl | undefined;
7
- export declare function writeControl(enabled: boolean): Promise<KeepaliveControl>;
6
+ export declare function readControl(directory: string): KeepaliveControl | undefined;
7
+ export declare function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl>;
8
8
  //# sourceMappingURL=control.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../lib/control.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,gBAAgB,GAAG;IAC3B,OAAO,EAAE,CAAC,CAAA;IACV,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,wBAAgB,WAAW,IAAI,gBAAgB,GAAG,SAAS,CAW1D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAY9E"}
1
+ {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../lib/control.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,gBAAgB,GAAG;IAC3B,OAAO,EAAE,CAAC,CAAA;IACV,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAW3E;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAYjG"}
@@ -17,13 +17,14 @@ export declare class KeepaliveEngine {
17
17
  private readonly config;
18
18
  private readonly store;
19
19
  private readonly logger;
20
+ private readonly directory;
20
21
  private timer;
21
22
  private controlTimer;
22
23
  private statusTimer;
23
24
  private controlUpdatedAt;
24
25
  private reconcilingStatus;
25
26
  private enabled;
26
- constructor(client: any, config: KeepaliveConfig, store: KeepaliveStore, logger: Logger);
27
+ constructor(client: any, config: KeepaliveConfig, store: KeepaliveStore, logger: Logger, directory: string);
27
28
  start(): void;
28
29
  stop(): void;
29
30
  /** True while a ping is in flight — used to block tool calls during a ping. */
@@ -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,EAAE,cAAc,EAAoB,MAAM,SAAS,CAAA;AAC/D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAQtC;;;;;;;;;;GAUG;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;IAX3B,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;IAQnC,KAAK,IAAI,IAAI;IAab,IAAI,IAAI,IAAI;IAUZ,+EAA+E;IAC/E,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI/B,OAAO,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAgF1B,SAAS;YA2BT,IAAI;YAmBJ,QAAQ;IA4CtB,gFAAgF;YAClE,UAAU;YAsCV,cAAc;IAiB5B,OAAO,CAAC,WAAW;IA2BnB,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,EAAE,cAAc,EAAoB,MAAM,SAAS,CAAA;AAC/D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAUtC;;;;;;;;;;GAUG;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,+EAA+E;IAC/E,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI/B,OAAO,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAkF1B,SAAS;YAqCT,IAAI;YA6BJ,QAAQ;IAkDtB,gFAAgF;YAClE,UAAU;YAsCV,cAAc;IAiB5B,OAAO,CAAC,WAAW;IA6BnB,sFAAsF;YACxE,eAAe;CAmBhC"}
@@ -8,8 +8,12 @@
8
8
  */
9
9
  export declare function stateFilePath(directory: string): string;
10
10
  export declare function stateDirectoryPath(): string;
11
- /** Runtime on/off control written by the TUI and read by the server plugin. */
12
- export declare function controlFilePath(): string;
11
+ /**
12
+ * Runtime on/off control written by the TUI and read by the server plugin.
13
+ * Scoped per directory (like the state file) so toggling keepalive in one
14
+ * project does not affect every other open opencode session.
15
+ */
16
+ export declare function controlFilePath(directory: string): string;
13
17
  /** Append-only log file for the server plugin — never stdout, which corrupts the TUI. */
14
18
  export declare function logFilePath(): string;
15
19
  //# sourceMappingURL=paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../lib/paths.ts"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAQ3C;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,yFAAyF;AACzF,wBAAgB,WAAW,IAAI,MAAM,CAEpC"}
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../lib/paths.ts"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAQ3C;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGzD;AAED,yFAAyF;AACzF,wBAAgB,WAAW,IAAI,MAAM,CAEpC"}
@@ -25,10 +25,14 @@ export type SessionKeepalive = {
25
25
  pingsSent: number;
26
26
  /** A real turn is currently running. */
27
27
  busy: boolean;
28
+ /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
29
+ busySince?: number;
28
30
  /** A keepalive ping is currently in flight. */
29
31
  warming: boolean;
30
32
  /** Within the warm window and actively scheduling pings. */
31
33
  active: boolean;
34
+ /** True while `armWindow` is resolving session metadata (async guard). */
35
+ arming?: boolean;
32
36
  /** Epoch ms of the last ping; retained only to ignore its late events. */
33
37
  lastPingAt?: number;
34
38
  lastPing?: PingRecord;
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,OAAO,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,8DAA8D;AAC9D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAA;IACtB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAA;IACjB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAA;IACpB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAA;IACjB,wCAAwC;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAA;IACf,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,CAAC,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAC7C,CAAA;AAED,qBAAa,cAAc;IAMnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAN9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsC;IAC/D,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,MAAM;IAKtC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIlC,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIpD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAmB3C,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,GAAG,IAAI,gBAAgB,EAAE;IAIzB,+EAA+E;IAC/E,OAAO,IAAI,IAAI;CA+BlB"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAG/C,MAAM,MAAM,UAAU,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,OAAO,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,8DAA8D;AAC9D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAA;IACtB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAA;IACjB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAA;IACpB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAA;IAClB,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAA;IACjB,wCAAwC;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,MAAM,EAAE,OAAO,CAAA;IACf,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG;IAC3B,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;IACf,QAAQ,CAAC,EAAE,UAAU,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IACzB,OAAO,EAAE,CAAC,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAC7C,CAAA;AAED,qBAAa,cAAc;IAMnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAN9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsC;IAC/D,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,OAAO,CAAS;gBAGH,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,MAAM;IAKtC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIlC,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAIpD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAmB3C,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B,GAAG,IAAI,gBAAgB,EAAE;IAIzB,+EAA+E;IAC/E,OAAO,IAAI,IAAI;CA+BlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"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,CA8CjE"}
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"}
package/lib/control.ts CHANGED
@@ -9,10 +9,10 @@ export type KeepaliveControl = {
9
9
  updatedAt: number
10
10
  }
11
11
 
12
- export function readControl(): KeepaliveControl | undefined {
12
+ export function readControl(directory: string): KeepaliveControl | undefined {
13
13
  try {
14
14
  const value = JSON.parse(
15
- readFileSync(controlFilePath(), "utf8"),
15
+ readFileSync(controlFilePath(directory), "utf8"),
16
16
  ) as Partial<KeepaliveControl>
17
17
  if (value.version !== 1 || typeof value.enabled !== "boolean") return undefined
18
18
  if (typeof value.updatedAt !== "number") return undefined
@@ -22,13 +22,13 @@ export function readControl(): KeepaliveControl | undefined {
22
22
  }
23
23
  }
24
24
 
25
- export async function writeControl(enabled: boolean): Promise<KeepaliveControl> {
25
+ export async function writeControl(enabled: boolean, directory: string): Promise<KeepaliveControl> {
26
26
  const value: KeepaliveControl = {
27
27
  version: 1,
28
28
  enabled,
29
29
  updatedAt: Date.now(),
30
30
  }
31
- const path = controlFilePath()
31
+ const path = controlFilePath(directory)
32
32
  const tmp = `${path}.${process.pid}.${value.updatedAt}.tmp`
33
33
  await mkdir(dirname(path), { recursive: true })
34
34
  await writeFile(tmp, `${JSON.stringify(value)}\n`, "utf8")
package/lib/keepalive.ts CHANGED
@@ -9,6 +9,8 @@ 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
14
 
13
15
  /**
14
16
  * The keepalive engine.
@@ -34,8 +36,9 @@ export class KeepaliveEngine {
34
36
  private readonly config: KeepaliveConfig,
35
37
  private readonly store: KeepaliveStore,
36
38
  private readonly logger: Logger,
39
+ private readonly directory: string,
37
40
  ) {
38
- const control = readControl()
41
+ const control = readControl(directory)
39
42
  this.enabled = control?.enabled ?? config.enabled
40
43
  this.controlUpdatedAt = control?.updatedAt ?? 0
41
44
  this.store.setEnabled(this.enabled)
@@ -98,6 +101,7 @@ export class KeepaliveEngine {
98
101
  if (info?.role === "user") {
99
102
  const s = this.store.ensure(sessionID)
100
103
  s.busy = true
104
+ s.busySince = Date.now()
101
105
  s.active = false
102
106
  this.store.persist()
103
107
  return
@@ -135,6 +139,7 @@ export class KeepaliveEngine {
135
139
 
136
140
  const s = this.store.ensure(sessionID)
137
141
  s.busy = true
142
+ s.busySince = Date.now()
138
143
  s.active = false
139
144
  this.store.persist()
140
145
  return
@@ -154,32 +159,52 @@ export class KeepaliveEngine {
154
159
  // Duplicate idle events (including late events from a ping) must not reset the
155
160
  // current idle stretch or extend its warm window.
156
161
  if (s.active && !s.busy) return
157
- s.busy = false
158
- await this.resolveSession(s)
162
+ // Serialize: only one armWindow may resolve metadata at a time per session.
163
+ // This prevents the session.status + session.idle double-event race from
164
+ // producing two concurrent resolveSession calls that can clobber each other.
165
+ if (s.arming) return
166
+ s.arming = true
167
+ try {
168
+ s.busy = false
169
+ s.busySince = undefined
170
+ await this.resolveSession(s)
159
171
 
160
- // A new turn may have started while session metadata was being resolved.
161
- if (s.busy || !this.enabled || !s.eligible) {
162
- s.active = false
172
+ // A new turn may have started while session metadata was being resolved.
173
+ if (s.busy || !this.enabled || !s.eligible) {
174
+ s.active = false
175
+ this.store.persist()
176
+ return
177
+ }
178
+
179
+ 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
163
187
  this.store.persist()
164
- return
188
+ this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
189
+ } finally {
190
+ s.arming = false
165
191
  }
166
-
167
- const now = Date.now()
168
- if (!s.lastResponseAt) s.lastResponseAt = now
169
- s.idleSince = now
170
- s.windowEndsAt = now + this.config.windowMs
171
- s.nextPingAt = now + this.config.intervalMs
172
- s.pingsSent = 0
173
- s.lastPing = undefined
174
- s.active = true
175
- this.store.persist()
176
- this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
177
192
  }
178
193
 
179
194
  private async tick(): Promise<void> {
180
195
  if (!this.enabled) return
181
196
  const now = Date.now()
182
197
  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
+
183
208
  if (!s.eligible || !s.active || s.busy || s.warming) continue
184
209
 
185
210
  if (now >= s.windowEndsAt) {
@@ -234,7 +259,13 @@ export class KeepaliveEngine {
234
259
  } finally {
235
260
  s.warming = false
236
261
  s.lastPingAt = Date.now()
237
- s.nextPingAt = Date.now() + jitter(this.config.intervalMs)
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
+ )
238
269
  this.store.persist()
239
270
  }
240
271
  }
@@ -296,7 +327,7 @@ export class KeepaliveEngine {
296
327
  }
297
328
 
298
329
  private pollControl(): void {
299
- const control = readControl()
330
+ const control = readControl(this.directory)
300
331
  if (!control || control.updatedAt <= this.controlUpdatedAt) return
301
332
  this.controlUpdatedAt = control.updatedAt
302
333
  if (control.enabled === this.enabled) return
@@ -309,8 +340,10 @@ export class KeepaliveEngine {
309
340
  session.active = false
310
341
  continue
311
342
  }
343
+ // Only re-arm sessions that have had a real turn (cache to warm) and
344
+ // are not currently occupied.
312
345
  if (!session.eligible || session.busy || session.warming) continue
313
- if (!session.lastResponseAt) session.lastResponseAt = now
346
+ if (!session.lastResponseAt) continue
314
347
  session.idleSince = now
315
348
  session.windowEndsAt = now + this.config.windowMs
316
349
  session.nextPingAt = now + this.config.intervalMs
package/lib/paths.ts CHANGED
@@ -25,9 +25,14 @@ export function stateDirectoryPath(): string {
25
25
  )
26
26
  }
27
27
 
28
- /** Runtime on/off control written by the TUI and read by the server plugin. */
29
- export function controlFilePath(): string {
30
- return join(stateDirectoryPath(), "control.json")
28
+ /**
29
+ * Runtime on/off control written by the TUI and read by the server plugin.
30
+ * Scoped per directory (like the state file) so toggling keepalive in one
31
+ * project does not affect every other open opencode session.
32
+ */
33
+ export function controlFilePath(directory: string): string {
34
+ const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)
35
+ return join(stateDirectoryPath(), `control-${key}.json`)
31
36
  }
32
37
 
33
38
  /** Append-only log file for the server plugin — never stdout, which corrupts the TUI. */
package/lib/state.ts CHANGED
@@ -30,10 +30,14 @@ export type SessionKeepalive = {
30
30
  pingsSent: number
31
31
  /** A real turn is currently running. */
32
32
  busy: boolean
33
+ /** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
34
+ busySince?: number
33
35
  /** A keepalive ping is currently in flight. */
34
36
  warming: boolean
35
37
  /** Within the warm window and actively scheduling pings. */
36
38
  active: boolean
39
+ /** True while `armWindow` is resolving session metadata (async guard). */
40
+ arming?: boolean
37
41
  /** Epoch ms of the last ping; retained only to ignore its late events. */
38
42
  lastPingAt?: number
39
43
  lastPing?: PingRecord
@@ -2,9 +2,14 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
2
  import { readControl, writeControl } from "../control"
3
3
 
4
4
  export function registerKeepaliveCommands(api: TuiPluginApi): void {
5
+ // The control file is scoped per directory, so toggling keepalive here only
6
+ // affects this project's server plugin — not other open opencode sessions.
7
+ // Read lazily: state paths may not be populated at registration time.
8
+ const directory = () => api.state.path.directory
9
+
5
10
  const setEnabled = async (enabled: boolean) => {
6
11
  try {
7
- await writeControl(enabled)
12
+ await writeControl(enabled, directory())
8
13
  api.ui.toast({
9
14
  variant: enabled ? "success" : "warning",
10
15
  title: "Cache keepalive",
@@ -27,7 +32,7 @@ export function registerKeepaliveCommands(api: TuiPluginApi): void {
27
32
  category: "Keepalive",
28
33
  namespace: "palette",
29
34
  slashName: "keepalive-toggle",
30
- run: () => setEnabled(!currentEnabled()),
35
+ run: () => setEnabled(!currentEnabled(directory())),
31
36
  },
32
37
  {
33
38
  name: "keepalive.on",
@@ -49,6 +54,6 @@ export function registerKeepaliveCommands(api: TuiPluginApi): void {
49
54
  })
50
55
  }
51
56
 
52
- function currentEnabled(): boolean {
53
- return readControl()?.enabled ?? true
57
+ function currentEnabled(directory: string): boolean {
58
+ return readControl(directory)?.enabled ?? true
54
59
  }
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.1",
4
+ "version": "0.1.3",
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",