@vikrant82/opencode-cache-keepalive 0.1.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/dist/index.d.ts +4 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +582 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/lib/auth.d.ts +11 -0
  6. package/dist/lib/auth.d.ts.map +1 -0
  7. package/dist/lib/config.d.ts +31 -0
  8. package/dist/lib/config.d.ts.map +1 -0
  9. package/dist/lib/control.d.ts +8 -0
  10. package/dist/lib/control.d.ts.map +1 -0
  11. package/dist/lib/keepalive.d.ts +42 -0
  12. package/dist/lib/keepalive.d.ts.map +1 -0
  13. package/dist/lib/logger.d.ts +9 -0
  14. package/dist/lib/logger.d.ts.map +1 -0
  15. package/dist/lib/model.d.ts +8 -0
  16. package/dist/lib/model.d.ts.map +1 -0
  17. package/dist/lib/paths.d.ts +13 -0
  18. package/dist/lib/paths.d.ts.map +1 -0
  19. package/dist/lib/state.d.ts +74 -0
  20. package/dist/lib/state.d.ts.map +1 -0
  21. package/dist/lib/system.d.ts +11 -0
  22. package/dist/lib/system.d.ts.map +1 -0
  23. package/dist/lib/tui/commands.d.ts +3 -0
  24. package/dist/lib/tui/commands.d.ts.map +1 -0
  25. package/dist/lib/tui/footer.d.ts +14 -0
  26. package/dist/lib/tui/footer.d.ts.map +1 -0
  27. package/dist/lib/tui/format.d.ts +3 -0
  28. package/dist/lib/tui/format.d.ts.map +1 -0
  29. package/dist/tui.d.ts +7 -0
  30. package/dist/tui.d.ts.map +1 -0
  31. package/lib/auth.ts +33 -0
  32. package/lib/config.ts +101 -0
  33. package/lib/control.ts +37 -0
  34. package/lib/keepalive.ts +345 -0
  35. package/lib/logger.ts +21 -0
  36. package/lib/model.ts +19 -0
  37. package/lib/paths.ts +31 -0
  38. package/lib/state.ts +157 -0
  39. package/lib/system.ts +19 -0
  40. package/lib/tui/commands.ts +54 -0
  41. package/lib/tui/footer.tsx +156 -0
  42. package/lib/tui/format.ts +13 -0
  43. package/package.json +71 -0
  44. package/tui.tsx +25 -0
@@ -0,0 +1,156 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { TextAttributes } from "@opentui/core"
4
+ import { createSignal, onCleanup, Show } from "solid-js"
5
+ import { readdirSync, readFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { stateDirectoryPath, stateFilePath } from "../paths"
8
+ import type { PersistedSession, PersistedState, PingRecord } from "../state"
9
+ import { kfmt, mmss } from "./format"
10
+
11
+ type Theme = Record<string, any>
12
+ type Status = "waiting" | "off" | "warming" | "busy" | "armed" | "cold"
13
+ type Derived = {
14
+ idleMs: number
15
+ sent: number
16
+ left: number
17
+ status: Status
18
+ lastPing?: PingRecord
19
+ }
20
+
21
+ /**
22
+ * Persistent readout mounted in the `sidebar_footer` slot. Shows time since the
23
+ * last real agent response plus keepalive pings sent / left. Data comes from the
24
+ * server plugin's state file; the idle timer ticks locally every second.
25
+ */
26
+ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessionID?: string }) {
27
+ const [now, setNow] = createSignal(Date.now())
28
+ const [state, setState] = createSignal<PersistedState | undefined>(undefined)
29
+
30
+ const load = () => {
31
+ const sessionID = props.sessionID
32
+ if (!sessionID) return setState(undefined)
33
+
34
+ const expected = readState(stateFilePath(props.directory))
35
+ if (expected?.sessions?.[sessionID]) return setState(expected)
36
+
37
+ try {
38
+ const match = readdirSync(stateDirectoryPath())
39
+ .filter((name) => name.startsWith("state-") && name.endsWith(".json"))
40
+ .map((name) => readState(join(stateDirectoryPath(), name)))
41
+ .find((snapshot) => snapshot?.sessions?.[sessionID])
42
+ setState(match)
43
+ } catch {
44
+ setState(undefined)
45
+ }
46
+ }
47
+
48
+ load()
49
+ const timer = setInterval(() => {
50
+ setNow(Date.now())
51
+ load()
52
+ }, 1000)
53
+ onCleanup(() => clearInterval(timer))
54
+
55
+ const derived = (): Derived | undefined => {
56
+ const snapshot = state()
57
+ const sessionID = props.sessionID
58
+ if (!snapshot || !sessionID) {
59
+ return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
60
+ }
61
+ const session: PersistedSession | undefined = snapshot.sessions?.[sessionID]
62
+ if (!session) return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
63
+
64
+ const interval = session.intervalMs || snapshot.intervalMs || 270_000
65
+ const idleMs = Math.max(0, now() - (session.lastResponseAt || now()))
66
+ const left = session.active
67
+ ? Math.max(0, Math.floor((session.windowEndsAt - now()) / interval))
68
+ : 0
69
+ const status: Status =
70
+ !snapshot.enabled || !session.eligible
71
+ ? "off"
72
+ : session.warming
73
+ ? "warming"
74
+ : session.busy
75
+ ? "busy"
76
+ : session.active
77
+ ? "armed"
78
+ : "cold"
79
+
80
+ return { idleMs, sent: session.pingsSent || 0, left, status, lastPing: session.lastPing }
81
+ }
82
+
83
+ const theme = props.theme
84
+
85
+ return (
86
+ <Show when={derived()}>
87
+ {(data: () => Derived) => (
88
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} gap={0}>
89
+ <box flexDirection="row" gap={1}>
90
+ <text fg={theme.primary} attributes={TextAttributes.BOLD}>
91
+ keepalive
92
+ </text>
93
+ <text fg={statusColor(theme, data().status)}>
94
+ {statusLabel(data().status)}
95
+ </text>
96
+ </box>
97
+
98
+ <box flexDirection="row" gap={1}>
99
+ <text fg={theme.textMuted}>idle</text>
100
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
101
+ {mmss(data().idleMs)}
102
+ </text>
103
+ <text fg={theme.textMuted}>· sent</text>
104
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
105
+ {String(data().sent)}
106
+ </text>
107
+ <text fg={theme.textMuted}>· left</text>
108
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
109
+ {String(data().left)}
110
+ </text>
111
+ </box>
112
+
113
+ <Show when={data().lastPing}>
114
+ {(ping: () => PingRecord) => (
115
+ <box flexDirection="row" gap={1}>
116
+ <text fg={ping().hit ? theme.success : theme.warning}>
117
+ {ping().hit ? "âś“ hit" : "âś— miss"}
118
+ </text>
119
+ <text fg={theme.textMuted}>
120
+ input {kfmt(ping().input)} · read {kfmt(ping().cacheRead)} ·
121
+ write {kfmt(ping().cacheWrite)}
122
+ </text>
123
+ </box>
124
+ )}
125
+ </Show>
126
+ </box>
127
+ )}
128
+ </Show>
129
+ )
130
+ }
131
+
132
+ function statusLabel(status: Status): string {
133
+ if (status === "waiting") return "waiting"
134
+ if (status === "warming") return "warming…"
135
+ if (status === "busy") return "busy"
136
+ if (status === "armed") return "armed"
137
+ if (status === "cold") return "cold"
138
+ return "off"
139
+ }
140
+
141
+ function statusColor(theme: Theme, status: Status): any {
142
+ if (status === "waiting") return theme.textMuted
143
+ if (status === "warming") return theme.accent
144
+ if (status === "busy") return theme.info
145
+ if (status === "armed") return theme.success
146
+ if (status === "cold") return theme.warning
147
+ return theme.textMuted
148
+ }
149
+
150
+ function readState(path: string): PersistedState | undefined {
151
+ try {
152
+ return JSON.parse(readFileSync(path, "utf8")) as PersistedState
153
+ } catch {
154
+ return undefined
155
+ }
156
+ }
@@ -0,0 +1,13 @@
1
+ export function mmss(ms: number): string {
2
+ const total = Math.max(0, Math.floor(ms / 1000))
3
+ const minutes = Math.floor(total / 60)
4
+ const seconds = total % 60
5
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`
6
+ }
7
+
8
+ export function kfmt(n: number): string {
9
+ if (!Number.isFinite(n) || n <= 0) return "0"
10
+ if (n < 1000) return `${Math.round(n)}`
11
+ const k = n / 1000
12
+ return `${k >= 10 ? Math.round(k) : k.toFixed(1)}k`
13
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@vikrant82/opencode-cache-keepalive",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "description": "OpenCode plugin that keeps provider prompt caches warm by sending invisible keepalive pings, with a live TUI readout",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./tui": {
19
+ "types": "./dist/tui.d.ts",
20
+ "import": "./tui.tsx"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "clean": "rm -rf dist",
25
+ "build": "npm run clean && tsup && tsc --emitDeclarationOnly",
26
+ "dev": "opencode plugin dev",
27
+ "typecheck": "tsc --noEmit",
28
+ "format": "prettier --write .",
29
+ "format:check": "prettier --check ."
30
+ },
31
+ "keywords": [
32
+ "opencode",
33
+ "opencode-plugin",
34
+ "plugin",
35
+ "prompt-cache",
36
+ "cache",
37
+ "keepalive",
38
+ "github-copilot",
39
+ "anthropic",
40
+ "claude",
41
+ "gpt"
42
+ ],
43
+ "author": "vikrant82",
44
+ "license": "AGPL-3.0-or-later",
45
+ "peerDependencies": {
46
+ "@opencode-ai/plugin": ">=1.4.3",
47
+ "@opentui/core": "^0.4.2",
48
+ "@opentui/solid": "^0.4.2",
49
+ "solid-js": "^1.9.12"
50
+ },
51
+ "dependencies": {
52
+ "@opencode-ai/sdk": "^1.4.3"
53
+ },
54
+ "devDependencies": {
55
+ "@opencode-ai/plugin": "^1.4.3",
56
+ "@opentui/core": "^0.4.2",
57
+ "@opentui/solid": "^0.4.2",
58
+ "@types/node": "^22.0.0",
59
+ "prettier": "^3.4.0",
60
+ "solid-js": "^1.9.12",
61
+ "tsup": "^8.5.1",
62
+ "typescript": "^5.7.0"
63
+ },
64
+ "files": [
65
+ "dist/",
66
+ "lib/",
67
+ "tui.tsx",
68
+ "README.md",
69
+ "LICENSE"
70
+ ]
71
+ }
package/tui.tsx ADDED
@@ -0,0 +1,25 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { registerKeepaliveCommands } from "./lib/tui/commands"
5
+ import { KeepaliveFooter } from "./lib/tui/footer"
6
+
7
+ const tui: TuiPluginModule["tui"] = async (api) => {
8
+ registerKeepaliveCommands(api)
9
+ api.slots.register({
10
+ // The built-in sidebar footer uses order 100. This slot is single-winner,
11
+ // so a lower order makes the keepalive readout the visible footer.
12
+ order: 0,
13
+ slots: {
14
+ sidebar_footer: (ctx, props) => (
15
+ <KeepaliveFooter
16
+ theme={ctx.theme.current}
17
+ directory={api.state.path.directory}
18
+ sessionID={props.session_id}
19
+ />
20
+ ),
21
+ },
22
+ })
23
+ }
24
+
25
+ export default { id: "opencode-cache-keepalive", tui } satisfies TuiPluginModule