@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.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +582 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth.d.ts +11 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/config.d.ts +31 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/control.d.ts +8 -0
- package/dist/lib/control.d.ts.map +1 -0
- package/dist/lib/keepalive.d.ts +42 -0
- package/dist/lib/keepalive.d.ts.map +1 -0
- package/dist/lib/logger.d.ts +9 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/model.d.ts +8 -0
- package/dist/lib/model.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/state.d.ts +74 -0
- package/dist/lib/state.d.ts.map +1 -0
- package/dist/lib/system.d.ts +11 -0
- package/dist/lib/system.d.ts.map +1 -0
- package/dist/lib/tui/commands.d.ts +3 -0
- package/dist/lib/tui/commands.d.ts.map +1 -0
- package/dist/lib/tui/footer.d.ts +14 -0
- package/dist/lib/tui/footer.d.ts.map +1 -0
- package/dist/lib/tui/format.d.ts +3 -0
- package/dist/lib/tui/format.d.ts.map +1 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.d.ts.map +1 -0
- package/lib/auth.ts +33 -0
- package/lib/config.ts +101 -0
- package/lib/control.ts +37 -0
- package/lib/keepalive.ts +345 -0
- package/lib/logger.ts +21 -0
- package/lib/model.ts +19 -0
- package/lib/paths.ts +31 -0
- package/lib/state.ts +157 -0
- package/lib/system.ts +19 -0
- package/lib/tui/commands.ts +54 -0
- package/lib/tui/footer.tsx +156 -0
- package/lib/tui/format.ts +13 -0
- package/package.json +71 -0
- 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
|