@vikrant82/opencode-cache-keepalive 0.1.6 → 0.2.0

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.
Files changed (44) hide show
  1. package/MIGRATION.md +57 -0
  2. package/README.md +75 -51
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1093 -649
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config.d.ts +8 -25
  7. package/dist/lib/config.d.ts.map +1 -1
  8. package/dist/lib/control.d.ts +10 -37
  9. package/dist/lib/control.d.ts.map +1 -1
  10. package/dist/lib/paths.d.ts +5 -12
  11. package/dist/lib/paths.d.ts.map +1 -1
  12. package/dist/lib/replay-warmer.d.ts +95 -0
  13. package/dist/lib/replay-warmer.d.ts.map +1 -0
  14. package/dist/lib/state.d.ts +15 -117
  15. package/dist/lib/state.d.ts.map +1 -1
  16. package/dist/lib/tui/commands.d.ts +1 -6
  17. package/dist/lib/tui/commands.d.ts.map +1 -1
  18. package/dist/lib/tui/footer.d.ts +5 -5
  19. package/dist/lib/tui/footer.d.ts.map +1 -1
  20. package/dist/lib/tui/format.d.ts.map +1 -1
  21. package/dist/lib/types.d.ts +123 -0
  22. package/dist/lib/types.d.ts.map +1 -0
  23. package/dist/tui.d.ts +3 -0
  24. package/dist/tui.d.ts.map +1 -1
  25. package/lib/config.ts +106 -74
  26. package/lib/control.ts +50 -86
  27. package/lib/paths.ts +12 -14
  28. package/lib/replay-warmer.ts +1200 -0
  29. package/lib/state.ts +171 -181
  30. package/lib/tui/commands.tsx +36 -144
  31. package/lib/tui/footer.tsx +54 -156
  32. package/lib/tui/format.ts +4 -0
  33. package/lib/types.ts +130 -0
  34. package/package.json +3 -2
  35. package/tui.tsx +40 -0
  36. package/dist/lib/keepalive.d.ts +0 -88
  37. package/dist/lib/keepalive.d.ts.map +0 -1
  38. package/dist/lib/model.d.ts +0 -8
  39. package/dist/lib/model.d.ts.map +0 -1
  40. package/dist/lib/system.d.ts +0 -11
  41. package/dist/lib/system.d.ts.map +0 -1
  42. package/lib/keepalive.ts +0 -665
  43. package/lib/model.ts +0 -19
  44. package/lib/system.ts +0 -19
package/lib/config.ts CHANGED
@@ -1,101 +1,133 @@
1
1
  export type KeepaliveConfig = {
2
- /** Master switch. When false the plugin registers no timers or hooks. */
3
2
  enabled: boolean
4
- /** Milliseconds between pings. Must stay under the shortest supported provider TTL. */
5
- intervalMs: number
6
- /**
7
- * How long to keep warming after the last real response before giving up and
8
- * letting the cache go cold. The default follows the Copilot Claude
9
- * write/read break-even; providers with automatic caching may be cheaper.
10
- */
11
- windowMs: number
12
- /**
13
- * Append a stable keepalive instruction to the system prompt so the model
14
- * answers a `~` ping with a single `~` token and never calls tools. The
15
- * instruction is added to real turns too, so the cached prefix stays identical.
16
- */
17
- injectSystemInstruction: boolean
18
- /** The single character/token used as the keepalive suffix. */
19
- pingToken: string
20
- /** Remove synthetic ping turns after measuring their cache usage. */
21
- revertPing: boolean
22
- /** Warm subagent/child sessions too. Off by default to avoid wasted pings. */
3
+ intervals: Record<string, number>
4
+ hosts: string[]
5
+ cacheReadFactor: number
6
+ missFactor: number
7
+ maxReplaysPerGap: "auto" | number
23
8
  includeChildSessions: boolean
9
+ replayTimeoutMs: number
10
+ maxStoredBytes: number
24
11
  debug: boolean
25
- /** providerID substrings eligible for warming (cache-controlled providers). */
26
- providerAllowlist: string[]
27
- /** modelID substrings eligible for warming. */
28
- modelAllowlist: string[]
29
12
  }
30
13
 
31
14
  const DEFAULTS: KeepaliveConfig = {
32
15
  enabled: true,
33
- intervalMs: 270_000, // 4.5 min — below the shortest supported cache TTL
34
- windowMs: 3_300_000, // 55 min — the Copilot Claude write/read break-even
35
- injectSystemInstruction: true,
36
- pingToken: "~",
37
- revertPing: true,
16
+ intervals: { claude: 285_000, gpt: 1_680_000 },
17
+ hosts: ["githubcopilot.com"],
18
+ cacheReadFactor: 0.1,
19
+ missFactor: 1,
20
+ maxReplaysPerGap: "auto",
38
21
  includeChildSessions: false,
22
+ replayTimeoutMs: 60_000,
23
+ maxStoredBytes: 67_108_864,
39
24
  debug: false,
40
- providerAllowlist: ["copilot"],
41
- modelAllowlist: ["claude", "anthropic", "sonnet", "opus", "haiku", "gpt"],
42
25
  }
43
26
 
44
- export function getConfig(options: Record<string, unknown> | undefined): KeepaliveConfig {
45
- const o = options ?? {}
46
- const env = process.env
27
+ const DEPRECATED = [
28
+ "intervalMs",
29
+ "intervalSeconds",
30
+ "windowMs",
31
+ "windowMinutes",
32
+ "revertPing",
33
+ "pingToken",
34
+ "injectSystemInstruction",
35
+ "providerAllowlist",
36
+ "modelAllowlist",
37
+ "claudeBusyWarm",
38
+ "claudeBusyWarmIntervalMs",
39
+ "claudeBusyWarmWindowMs",
40
+ ]
41
+ const warnedDeprecated = new Set<string>()
47
42
 
43
+ export function getConfig(
44
+ options: Record<string, unknown> | undefined,
45
+ warn: (message: string) => void = () => {},
46
+ ): KeepaliveConfig {
47
+ const option = options ?? {}
48
+ const env = process.env
49
+ for (const key of DEPRECATED) {
50
+ if (key in option && !warnedDeprecated.has(key)) {
51
+ warnedDeprecated.add(key)
52
+ warn(`deprecated option ${key} is ignored`)
53
+ }
54
+ }
55
+ const parseJson = (raw: string | undefined): unknown => {
56
+ if (!raw) return undefined
57
+ try {
58
+ return JSON.parse(raw)
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+ const intervals = objectOption(
64
+ option.intervals ?? parseJson(env.OPENCODE_KEEPALIVE_INTERVALS),
65
+ DEFAULTS.intervals,
66
+ )
67
+ const hosts = listOption(
68
+ option.hosts ?? parseJson(env.OPENCODE_KEEPALIVE_HOSTS),
69
+ DEFAULTS.hosts,
70
+ )
71
+ const maxRaw = option.maxReplaysPerGap ?? env.OPENCODE_KEEPALIVE_MAX_REPLAYS_PER_GAP
72
+ const maxReplaysPerGap =
73
+ maxRaw === "auto"
74
+ ? "auto"
75
+ : positiveNumber(maxRaw) !== undefined
76
+ ? positiveNumber(maxRaw)!
77
+ : DEFAULTS.maxReplaysPerGap
48
78
  return {
49
- enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== "false" && DEFAULTS.enabled),
50
- intervalMs: durationOpt(
51
- o.intervalMs,
52
- o.intervalSeconds,
53
- numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs),
79
+ enabled: boolOption(
80
+ option.enabled,
81
+ env.OPENCODE_KEEPALIVE_ENABLED === undefined
82
+ ? DEFAULTS.enabled
83
+ : env.OPENCODE_KEEPALIVE_ENABLED !== "false",
54
84
  ),
55
- windowMs: durationOpt(
56
- o.windowMs,
57
- o.windowMinutes ? Number(o.windowMinutes) * 60 : undefined,
58
- numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs),
85
+ intervals,
86
+ hosts,
87
+ cacheReadFactor:
88
+ positiveNumber(option.cacheReadFactor ?? env.OPENCODE_KEEPALIVE_CACHE_READ_FACTOR) ??
89
+ DEFAULTS.cacheReadFactor,
90
+ missFactor:
91
+ positiveNumber(option.missFactor ?? env.OPENCODE_KEEPALIVE_MISS_FACTOR) ??
92
+ DEFAULTS.missFactor,
93
+ maxReplaysPerGap,
94
+ includeChildSessions: boolOption(
95
+ option.includeChildSessions,
96
+ env.OPENCODE_KEEPALIVE_INCLUDE_CHILD_SESSIONS === "true" ||
97
+ DEFAULTS.includeChildSessions,
59
98
  ),
60
- injectSystemInstruction: boolOpt(
61
- o.injectSystemInstruction,
62
- DEFAULTS.injectSystemInstruction,
63
- ),
64
- pingToken:
65
- typeof o.pingToken === "string" && o.pingToken.length > 0
66
- ? o.pingToken
67
- : DEFAULTS.pingToken,
68
- revertPing: boolOpt(
69
- o.revertPing,
70
- env.OPENCODE_KEEPALIVE_REVERT_PING !== "false" && DEFAULTS.revertPing,
71
- ),
72
- includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),
73
- debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
74
- providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),
75
- modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist),
99
+ replayTimeoutMs:
100
+ positiveNumber(option.replayTimeoutMs ?? env.OPENCODE_KEEPALIVE_REPLAY_TIMEOUT_MS) ??
101
+ DEFAULTS.replayTimeoutMs,
102
+ maxStoredBytes:
103
+ positiveNumber(option.maxStoredBytes ?? env.OPENCODE_KEEPALIVE_MAX_STORED_BYTES) ??
104
+ DEFAULTS.maxStoredBytes,
105
+ debug: boolOption(option.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
76
106
  }
77
107
  }
78
108
 
79
- function boolOpt(value: unknown, fallback: boolean): boolean {
109
+ function boolOption(value: unknown, fallback: boolean): boolean {
80
110
  return typeof value === "boolean" ? value : fallback
81
111
  }
82
112
 
83
- function listOpt(value: unknown, fallback: string[]): string[] {
84
- if (!Array.isArray(value)) return fallback
85
- const filtered = value.filter((item): item is string => typeof item === "string")
86
- return filtered.length > 0 ? filtered : fallback
113
+ function positiveNumber(value: unknown): number | undefined {
114
+ const parsed = typeof value === "string" ? Number(value) : value
115
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
87
116
  }
88
117
 
89
- /** Accept an explicit `*Ms` option, a `*Seconds` option, or fall back. */
90
- function durationOpt(ms: unknown, seconds: unknown, fallback: number): number {
91
- if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms
92
- if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0)
93
- return seconds * 1000
94
- return fallback
118
+ function objectOption(value: unknown, fallback: Record<string, number>): Record<string, number> {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
120
+ const entries = Object.entries(value).flatMap(([key, interval]) => {
121
+ const parsed = positiveNumber(interval)
122
+ return parsed === undefined ? [] : [[key.toLowerCase(), parsed] as const]
123
+ })
124
+ return entries.length ? Object.fromEntries(entries) : fallback
95
125
  }
96
126
 
97
- function numEnv(raw: string | undefined, fallback: number): number {
98
- if (!raw) return fallback
99
- const value = Number(raw)
100
- return Number.isFinite(value) && value > 0 ? value : fallback
127
+ function listOption(value: unknown, fallback: string[]): string[] {
128
+ if (!Array.isArray(value)) return fallback
129
+ const values = value.filter(
130
+ (item): item is string => typeof item === "string" && item.length > 0,
131
+ )
132
+ return values.length ? values : fallback
101
133
  }
package/lib/control.ts CHANGED
@@ -4,90 +4,57 @@ import { mkdir, rename, writeFile } from "node:fs/promises"
4
4
  import { dirname } from "node:path"
5
5
  import { controlFilePath } from "./paths"
6
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
7
+ const VERSION = 2
8
+ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
11
9
 
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
- */
17
- export type KeepaliveControl = {
18
- version: 1
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. */
24
- updatedAt: number
25
- }
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
- }
10
+ export type SessionControl = { enabled: boolean; updatedAt: number }
11
+ export type SessionControlFile = { version: 2; sessions: Record<string, SessionControl> }
32
12
 
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
- */
47
- export function readControl(directory: string): KeepaliveControl | undefined {
13
+ /** Read current session overrides; old folder-level controls are intentionally ignored. */
14
+ export function readSessionControls(directory: string, now = Date.now()): SessionControlFile {
48
15
  try {
49
16
  const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8")) as Record<
50
17
  string,
51
18
  unknown
52
19
  >
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 } : {}),
20
+ if (value.version !== VERSION || !value.sessions || typeof value.sessions !== "object")
21
+ return { version: VERSION, sessions: {} }
22
+ const sessions: Record<string, SessionControl> = {}
23
+ for (const [id, raw] of Object.entries(value.sessions)) {
24
+ if (
25
+ !raw ||
26
+ typeof raw !== "object" ||
27
+ typeof (raw as SessionControl).enabled !== "boolean" ||
28
+ typeof (raw as SessionControl).updatedAt !== "number" ||
29
+ (raw as SessionControl).updatedAt < now - MAX_AGE_MS
30
+ )
31
+ continue
32
+ sessions[id] = {
33
+ enabled: (raw as SessionControl).enabled,
34
+ updatedAt: (raw as SessionControl).updatedAt,
35
+ }
60
36
  }
37
+ return { version: VERSION, sessions }
61
38
  } catch {
62
- return undefined
39
+ return { version: VERSION, sessions: {} }
63
40
  }
64
41
  }
65
42
 
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(
43
+ export function readSessionControl(
74
44
  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
- }
45
+ sessionID: string,
46
+ ): SessionControl | undefined {
47
+ return readSessionControls(directory).sessions[sessionID]
48
+ }
86
49
 
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))
50
+ /** Atomically update only this session key and prune expired overrides. */
51
+ export async function setSessionEnabled(
52
+ directory: string,
53
+ sessionID: string,
54
+ enabled: boolean,
55
+ now = Date.now(),
56
+ ): Promise<SessionControlFile> {
57
+ const run = updateQueue.then(() => writeSessionControl(directory, sessionID, enabled, now))
91
58
  updateQueue = run.then(
92
59
  () => undefined,
93
60
  () => undefined,
@@ -97,17 +64,19 @@ export async function updateControl(
97
64
 
98
65
  let updateQueue: Promise<void> = Promise.resolve()
99
66
 
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)
105
- const value: KeepaliveControl = {
106
- version: 1,
107
- ...(enabled !== undefined ? { enabled } : {}),
108
- ...(intervalMs !== undefined ? { intervalMs } : {}),
109
- updatedAt: Math.max(Date.now(), (current?.updatedAt ?? 0) + 1),
110
- }
67
+ async function writeSessionControl(
68
+ directory: string,
69
+ sessionID: string,
70
+ enabled: boolean,
71
+ now: number,
72
+ ): Promise<SessionControlFile> {
73
+ const current = readSessionControls(directory, now)
74
+ const cutoff = now - MAX_AGE_MS
75
+ const sessions = Object.fromEntries(
76
+ Object.entries(current.sessions).filter(([, value]) => value.updatedAt >= cutoff),
77
+ )
78
+ sessions[sessionID] = { enabled, updatedAt: now }
79
+ const value: SessionControlFile = { version: VERSION, sessions }
111
80
  const path = controlFilePath(directory)
112
81
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
113
82
  await mkdir(dirname(path), { recursive: true })
@@ -115,8 +84,3 @@ async function writeMerged(directory: string, patch: ControlPatch): Promise<Keep
115
84
  await rename(tmp, path)
116
85
  return value
117
86
  }
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/paths.ts CHANGED
@@ -2,17 +2,17 @@ import { createHash } from "node:crypto"
2
2
  import { homedir } from "node:os"
3
3
  import { join } from "node:path"
4
4
 
5
- /**
6
- * Absolute path to the JSON state file shared between the server plugin (writer)
7
- * and the TUI plugin (reader). Both processes run on the same machine, so a file
8
- * under opencode's plugin storage dir is the simplest cross-process channel.
9
- *
10
- * Mirrors the storage layout used by other opencode plugins:
11
- * <XDG_DATA_HOME | ~/.local/share>/opencode/storage/plugin/keepalive/state.json
12
- */
13
- export function stateFilePath(directory: string): string {
14
- const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)
15
- return join(stateDirectoryPath(), `state-${key}.json`)
5
+ /** Stable short directory key shared by that directory's state-file readers/writers. */
6
+ export function stateDirectoryKey(directory: string): string {
7
+ return createHash("sha256").update(directory).digest("hex").slice(0, 16)
8
+ }
9
+
10
+ /** Filename for one process/plugin-instance state snapshot. */
11
+ export function instanceStateFilePath(directory: string, pid: number, instanceId: string): string {
12
+ return join(
13
+ stateDirectoryPath(),
14
+ `state-v2-${stateDirectoryKey(directory)}-${pid}-${instanceId}.json`,
15
+ )
16
16
  }
17
17
 
18
18
  export function stateDirectoryPath(): string {
@@ -26,9 +26,7 @@ export function stateDirectoryPath(): string {
26
26
  }
27
27
 
28
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.
29
+ * Session runtime controls are stored by directory, with entries keyed by session ID.
32
30
  */
33
31
  export function controlFilePath(directory: string): string {
34
32
  const key = createHash("sha256").update(directory).digest("hex").slice(0, 16)