@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/index.js +313 -75
- package/dist/index.js.map +1 -1
- package/dist/lib/control.d.ts +34 -1
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/keepalive.d.ts +53 -8
- package/dist/lib/keepalive.d.ts.map +1 -1
- package/dist/lib/state.d.ts +48 -5
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +7 -0
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts +8 -0
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/lib/control.ts +96 -11
- package/lib/keepalive.ts +364 -89
- package/lib/state.ts +57 -7
- package/lib/tui/commands.tsx +184 -0
- package/lib/tui/footer.tsx +33 -6
- package/lib/tui/format.ts +36 -0
- package/package.json +3 -1
- package/lib/tui/commands.ts +0 -59
package/lib/state.ts
CHANGED
|
@@ -12,6 +12,40 @@ export type PingRecord = {
|
|
|
12
12
|
output: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Request settings of the last real user turn. A ping must replay them exactly:
|
|
17
|
+
* a prompt without explicit settings runs as opencode's default agent with no
|
|
18
|
+
* variant, which changes the request shape (missing the cache) and overwrites
|
|
19
|
+
* the session's persisted agent/model/variant.
|
|
20
|
+
*/
|
|
21
|
+
export type PingRequest = {
|
|
22
|
+
agent?: string
|
|
23
|
+
providerID: string
|
|
24
|
+
modelID: string
|
|
25
|
+
variant?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bookkeeping for the ping request currently in flight. */
|
|
29
|
+
export type InflightPing = {
|
|
30
|
+
/** Epoch ms the ping attempt began (before the pre-flight status check). */
|
|
31
|
+
startedAt: number
|
|
32
|
+
/** The prompt request has been issued. */
|
|
33
|
+
sent: boolean
|
|
34
|
+
/** The ping's own user message, identified by its ping-token text part. */
|
|
35
|
+
messageID?: string
|
|
36
|
+
/**
|
|
37
|
+
* New user messages seen during the ping whose text has not yet been observed.
|
|
38
|
+
* Each is classified by its first text part: the ping token marks our own
|
|
39
|
+
* message, anything else a real turn.
|
|
40
|
+
*/
|
|
41
|
+
pending: Map<string, unknown>
|
|
42
|
+
/**
|
|
43
|
+
* A real user message appeared while the ping was in flight. opencode joins such
|
|
44
|
+
* a prompt to the running ping loop, so the rest of the run is real work.
|
|
45
|
+
*/
|
|
46
|
+
interrupted: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
/** Live per-session bookkeeping held by the server plugin. */
|
|
16
50
|
export type SessionKeepalive = {
|
|
17
51
|
sessionID: string
|
|
@@ -30,15 +64,20 @@ export type SessionKeepalive = {
|
|
|
30
64
|
pingsSent: number
|
|
31
65
|
/** A real turn is currently running. */
|
|
32
66
|
busy: boolean
|
|
33
|
-
/** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
|
|
34
|
-
busySince?: number
|
|
35
67
|
/** A keepalive ping is currently in flight. */
|
|
36
68
|
warming: boolean
|
|
69
|
+
/** The in-flight ping; set exactly while `warming` is true. */
|
|
70
|
+
ping?: InflightPing
|
|
71
|
+
/** ID and creation time of the newest user message seen, to tell new turns from re-emitted old messages. */
|
|
72
|
+
lastUserID?: string
|
|
73
|
+
lastUserAt?: number
|
|
74
|
+
/** Settings of the last real user turn, replayed by pings. */
|
|
75
|
+
request?: PingRequest
|
|
37
76
|
/** Within the warm window and actively scheduling pings. */
|
|
38
77
|
active: boolean
|
|
39
78
|
/** True while `armWindow` is resolving session metadata (async guard). */
|
|
40
79
|
arming?: boolean
|
|
41
|
-
/** Epoch ms
|
|
80
|
+
/** Epoch ms the last sent ping finished; ignores its late events and marks the last cache touch. */
|
|
42
81
|
lastPingAt?: number
|
|
43
82
|
lastPing?: PingRecord
|
|
44
83
|
}
|
|
@@ -72,18 +111,25 @@ export class KeepaliveStore {
|
|
|
72
111
|
private readonly sessions = new Map<string, SessionKeepalive>()
|
|
73
112
|
private writeQueue: Promise<void> = Promise.resolve()
|
|
74
113
|
private enabled: boolean
|
|
114
|
+
private intervalMs: number
|
|
75
115
|
|
|
76
116
|
constructor(
|
|
77
117
|
private readonly config: KeepaliveConfig,
|
|
78
118
|
private readonly directory: string,
|
|
79
119
|
) {
|
|
80
120
|
this.enabled = config.enabled
|
|
121
|
+
this.intervalMs = config.intervalMs
|
|
81
122
|
}
|
|
82
123
|
|
|
83
124
|
setEnabled(enabled: boolean): void {
|
|
84
125
|
this.enabled = enabled
|
|
85
126
|
}
|
|
86
127
|
|
|
128
|
+
/** Effective ping interval (runtime override or config) reported to the TUI. */
|
|
129
|
+
setIntervalMs(intervalMs: number): void {
|
|
130
|
+
this.intervalMs = intervalMs
|
|
131
|
+
}
|
|
132
|
+
|
|
87
133
|
get(sessionID: string): SessionKeepalive | undefined {
|
|
88
134
|
return this.sessions.get(sessionID)
|
|
89
135
|
}
|
|
@@ -115,13 +161,16 @@ export class KeepaliveStore {
|
|
|
115
161
|
return [...this.sessions.values()]
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
|
|
166
|
+
* returned promise resolves once this snapshot and every earlier one are written.
|
|
167
|
+
*/
|
|
168
|
+
persist(): Promise<void> {
|
|
120
169
|
const snapshot: PersistedState = {
|
|
121
170
|
version: 1,
|
|
122
171
|
updatedAt: Date.now(),
|
|
123
172
|
enabled: this.enabled,
|
|
124
|
-
intervalMs: this.
|
|
173
|
+
intervalMs: this.intervalMs,
|
|
125
174
|
windowMs: this.config.windowMs,
|
|
126
175
|
sessions: Object.fromEntries(
|
|
127
176
|
this.all().map((s) => [
|
|
@@ -133,7 +182,7 @@ export class KeepaliveStore {
|
|
|
133
182
|
idleSince: s.idleSince,
|
|
134
183
|
windowEndsAt: s.windowEndsAt,
|
|
135
184
|
nextPingAt: s.nextPingAt,
|
|
136
|
-
intervalMs: this.
|
|
185
|
+
intervalMs: this.intervalMs,
|
|
137
186
|
pingsSent: s.pingsSent,
|
|
138
187
|
busy: s.busy,
|
|
139
188
|
warming: s.warming,
|
|
@@ -146,6 +195,7 @@ export class KeepaliveStore {
|
|
|
146
195
|
|
|
147
196
|
const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
|
|
148
197
|
this.writeQueue = this.writeQueue.then(write, write)
|
|
198
|
+
return this.writeQueue
|
|
149
199
|
}
|
|
150
200
|
}
|
|
151
201
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { TuiDialogSelectOption, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import { readFileSync } from "node:fs"
|
|
5
|
+
import {
|
|
6
|
+
type ControlPatch,
|
|
7
|
+
isValidInterval,
|
|
8
|
+
MAX_INTERVAL_MS,
|
|
9
|
+
MIN_INTERVAL_MS,
|
|
10
|
+
readControl,
|
|
11
|
+
updateControl,
|
|
12
|
+
} from "../control"
|
|
13
|
+
import { stateFilePath } from "../paths"
|
|
14
|
+
import { formatDuration, parseDuration } from "./format"
|
|
15
|
+
|
|
16
|
+
/** Interval presets matched to common provider prompt-cache lifetimes. */
|
|
17
|
+
const INTERVAL_PRESETS: { ms: number; description: string }[] = [
|
|
18
|
+
{ ms: 270_000, description: "5-minute caches (Claude / Anthropic)" },
|
|
19
|
+
{ ms: 1_700_000, description: "30-minute caches (GPT / OpenAI)" },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
type IntervalChoice = number | "config" | "custom"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register the keepalive commands. The control file is scoped per directory, so
|
|
26
|
+
* toggling keepalive or changing its interval here only affects this project's
|
|
27
|
+
* server plugin — not other open opencode sessions. The server polls the file
|
|
28
|
+
* every second, so changes apply without a restart.
|
|
29
|
+
*/
|
|
30
|
+
export function registerKeepaliveCommands(api: TuiPluginApi): void {
|
|
31
|
+
// Read lazily: state paths may not be populated at registration time.
|
|
32
|
+
const directory = () => api.state.path.directory
|
|
33
|
+
|
|
34
|
+
const apply = async (
|
|
35
|
+
patch: ControlPatch,
|
|
36
|
+
message: string,
|
|
37
|
+
variant: "success" | "warning" | "info",
|
|
38
|
+
) => {
|
|
39
|
+
try {
|
|
40
|
+
await updateControl(directory(), patch)
|
|
41
|
+
api.ui.toast({ variant, title: "Cache keepalive", message })
|
|
42
|
+
} catch (error) {
|
|
43
|
+
api.ui.toast({
|
|
44
|
+
variant: "error",
|
|
45
|
+
title: "Cache keepalive",
|
|
46
|
+
message: error instanceof Error ? error.message : String(error),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const setEnabled = (enabled: boolean) =>
|
|
52
|
+
apply({ enabled }, enabled ? "Enabled" : "Disabled", enabled ? "success" : "warning")
|
|
53
|
+
|
|
54
|
+
const setPingInterval = (intervalMs: number | null) =>
|
|
55
|
+
apply(
|
|
56
|
+
{ intervalMs },
|
|
57
|
+
intervalMs === null
|
|
58
|
+
? "Ping interval reset to the plugin default"
|
|
59
|
+
: `Ping interval ${formatDuration(intervalMs)}`,
|
|
60
|
+
"info",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const showCustomInterval = (current: number | undefined) => {
|
|
64
|
+
api.ui.dialog.replace(() => (
|
|
65
|
+
<api.ui.DialogPrompt
|
|
66
|
+
title="Custom ping interval"
|
|
67
|
+
placeholder="e.g. 4m30s, 270s, 12m (a bare number means minutes)"
|
|
68
|
+
value={current ? formatDuration(current).replace(/ /g, "") : undefined}
|
|
69
|
+
onConfirm={(raw) => {
|
|
70
|
+
const ms = parseDuration(raw)
|
|
71
|
+
if (ms === undefined || !isValidInterval(ms)) {
|
|
72
|
+
api.ui.toast({
|
|
73
|
+
variant: "error",
|
|
74
|
+
title: "Cache keepalive",
|
|
75
|
+
message: `Enter a duration between ${formatDuration(MIN_INTERVAL_MS)} and ${formatDuration(MAX_INTERVAL_MS)}, e.g. 4m30s`,
|
|
76
|
+
})
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
api.ui.dialog.clear()
|
|
80
|
+
void setPingInterval(ms)
|
|
81
|
+
}}
|
|
82
|
+
onCancel={() => api.ui.dialog.clear()}
|
|
83
|
+
/>
|
|
84
|
+
))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const showInterval = () => {
|
|
88
|
+
const override = readControl(directory())?.intervalMs
|
|
89
|
+
const options: TuiDialogSelectOption<IntervalChoice>[] = INTERVAL_PRESETS.map((preset) => ({
|
|
90
|
+
title: formatDuration(preset.ms),
|
|
91
|
+
value: preset.ms,
|
|
92
|
+
description: preset.description,
|
|
93
|
+
}))
|
|
94
|
+
if (override !== undefined && !INTERVAL_PRESETS.some((preset) => preset.ms === override)) {
|
|
95
|
+
options.push({
|
|
96
|
+
title: formatDuration(override),
|
|
97
|
+
value: override,
|
|
98
|
+
description: "Current custom interval",
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
options.push(
|
|
102
|
+
{
|
|
103
|
+
title: "Custom…",
|
|
104
|
+
value: "custom",
|
|
105
|
+
description: "Enter a duration, e.g. 4m30s or 12m",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
title: "Plugin default",
|
|
109
|
+
value: "config",
|
|
110
|
+
description: "Clear the override and use the configured intervalMs",
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
api.ui.dialog.replace(() => (
|
|
115
|
+
<api.ui.DialogSelect
|
|
116
|
+
title="Keepalive ping interval"
|
|
117
|
+
options={options}
|
|
118
|
+
current={override ?? "config"}
|
|
119
|
+
onSelect={(option) => {
|
|
120
|
+
const choice = option.value as IntervalChoice
|
|
121
|
+
if (choice === "custom") {
|
|
122
|
+
showCustomInterval(override)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
api.ui.dialog.clear()
|
|
126
|
+
void setPingInterval(choice === "config" ? null : choice)
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
api.keymap.registerLayer({
|
|
133
|
+
commands: [
|
|
134
|
+
{
|
|
135
|
+
name: "keepalive.toggle",
|
|
136
|
+
title: "Toggle cache keepalive",
|
|
137
|
+
category: "Keepalive",
|
|
138
|
+
namespace: "palette",
|
|
139
|
+
slashName: "keepalive-toggle",
|
|
140
|
+
run: () => setEnabled(!currentEnabled(directory())),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "keepalive.on",
|
|
144
|
+
title: "Enable cache keepalive",
|
|
145
|
+
category: "Keepalive",
|
|
146
|
+
namespace: "palette",
|
|
147
|
+
slashName: "keepalive-on",
|
|
148
|
+
run: () => setEnabled(true),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "keepalive.off",
|
|
152
|
+
title: "Disable cache keepalive",
|
|
153
|
+
category: "Keepalive",
|
|
154
|
+
namespace: "palette",
|
|
155
|
+
slashName: "keepalive-off",
|
|
156
|
+
run: () => setEnabled(false),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "keepalive.interval",
|
|
160
|
+
title: "Set cache keepalive ping interval",
|
|
161
|
+
category: "Keepalive",
|
|
162
|
+
namespace: "palette",
|
|
163
|
+
slashName: "keepalive-interval",
|
|
164
|
+
run: showInterval,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Effective on/off state: the runtime override if set, else what this project's
|
|
172
|
+
* server last reported (its configured default), else on.
|
|
173
|
+
*/
|
|
174
|
+
function currentEnabled(directory: string): boolean {
|
|
175
|
+
const override = readControl(directory)?.enabled
|
|
176
|
+
if (override !== undefined) return override
|
|
177
|
+
try {
|
|
178
|
+
const state = JSON.parse(readFileSync(stateFilePath(directory), "utf8"))
|
|
179
|
+
if (typeof state?.enabled === "boolean") return state.enabled
|
|
180
|
+
} catch {
|
|
181
|
+
// No server snapshot yet.
|
|
182
|
+
}
|
|
183
|
+
return true
|
|
184
|
+
}
|
package/lib/tui/footer.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { readdirSync, readFileSync } from "node:fs"
|
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { stateDirectoryPath, stateFilePath } from "../paths"
|
|
8
8
|
import type { PersistedSession, PersistedState, PingRecord } from "../state"
|
|
9
|
-
import { kfmt, mmss } from "./format"
|
|
9
|
+
import { formatDuration, kfmt, mmss } from "./format"
|
|
10
10
|
|
|
11
11
|
type Theme = Record<string, any>
|
|
12
12
|
type Status = "waiting" | "off" | "warming" | "busy" | "armed" | "cold"
|
|
@@ -16,6 +16,8 @@ type Derived = {
|
|
|
16
16
|
left: number
|
|
17
17
|
status: Status
|
|
18
18
|
lastPing?: PingRecord
|
|
19
|
+
/** Effective ping interval for this project, when the server has reported it. */
|
|
20
|
+
intervalMs?: number
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -60,12 +62,25 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
|
|
|
60
62
|
const snapshot = state()
|
|
61
63
|
const sessionID = props.sessionID
|
|
62
64
|
if (!snapshot || !sessionID) {
|
|
63
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
idleMs: 0,
|
|
67
|
+
sent: 0,
|
|
68
|
+
left: 0,
|
|
69
|
+
status: "waiting",
|
|
70
|
+
intervalMs: snapshot?.intervalMs,
|
|
71
|
+
}
|
|
64
72
|
}
|
|
65
73
|
const session: PersistedSession | undefined = snapshot.sessions?.[sessionID]
|
|
66
|
-
if (!session)
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
if (!session)
|
|
75
|
+
return {
|
|
76
|
+
idleMs: 0,
|
|
77
|
+
sent: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
status: "waiting",
|
|
80
|
+
intervalMs: snapshot.intervalMs,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const interval = snapshot.intervalMs || session.intervalMs || 270_000
|
|
69
84
|
const idleMs = Math.max(0, now() - (session.lastResponseAt || now()))
|
|
70
85
|
// `nextPingAt` is the scheduler's source of truth and includes the
|
|
71
86
|
// per-ping jitter. Counting from it keeps the two counters in step.
|
|
@@ -84,7 +99,14 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
|
|
|
84
99
|
? "armed"
|
|
85
100
|
: "cold"
|
|
86
101
|
|
|
87
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
idleMs,
|
|
104
|
+
sent: session.pingsSent || 0,
|
|
105
|
+
left,
|
|
106
|
+
status,
|
|
107
|
+
lastPing: session.lastPing,
|
|
108
|
+
intervalMs: interval,
|
|
109
|
+
}
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
const theme = props.theme
|
|
@@ -100,6 +122,11 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
|
|
|
100
122
|
<text fg={statusColor(theme, data().status)}>
|
|
101
123
|
{statusLabel(data().status)}
|
|
102
124
|
</text>
|
|
125
|
+
<Show when={data().intervalMs}>
|
|
126
|
+
{(interval: () => number) => (
|
|
127
|
+
<text fg={theme.textMuted}>every {formatDuration(interval())}</text>
|
|
128
|
+
)}
|
|
129
|
+
</Show>
|
|
103
130
|
</box>
|
|
104
131
|
|
|
105
132
|
<box flexDirection="row" gap={1}>
|
package/lib/tui/format.ts
CHANGED
|
@@ -11,3 +11,39 @@ export function kfmt(n: number): string {
|
|
|
11
11
|
const k = n / 1000
|
|
12
12
|
return `${k >= 10 ? Math.round(k) : k.toFixed(1)}k`
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1_000 }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a human duration such as "4m30s", "4.5m", "270s", "1h" or "28m 20s".
|
|
19
|
+
* A bare number means minutes. Returns undefined for anything unparseable;
|
|
20
|
+
* range checks are the caller's concern.
|
|
21
|
+
*/
|
|
22
|
+
export function parseDuration(input: string): number | undefined {
|
|
23
|
+
const text = input.trim().toLowerCase()
|
|
24
|
+
if (!text) return undefined
|
|
25
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return Math.round(Number(text) * UNIT_MS.m)
|
|
26
|
+
|
|
27
|
+
const segment = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*/y
|
|
28
|
+
let total = 0
|
|
29
|
+
while (segment.lastIndex < text.length) {
|
|
30
|
+
const match = segment.exec(text)
|
|
31
|
+
if (!match) return undefined
|
|
32
|
+
total += Number(match[1]) * UNIT_MS[match[2][0]]
|
|
33
|
+
}
|
|
34
|
+
return Math.round(total)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Compact duration label, e.g. 270000 -> "4m 30s", 3600000 -> "1h". */
|
|
38
|
+
export function formatDuration(ms: number): string {
|
|
39
|
+
const total = Math.max(0, Math.round(ms / 1000))
|
|
40
|
+
const hours = Math.floor(total / 3600)
|
|
41
|
+
const minutes = Math.floor((total % 3600) / 60)
|
|
42
|
+
const seconds = total % 60
|
|
43
|
+
const parts = [
|
|
44
|
+
hours ? `${hours}h` : "",
|
|
45
|
+
minutes ? `${minutes}m` : "",
|
|
46
|
+
seconds ? `${seconds}s` : "",
|
|
47
|
+
].filter(Boolean)
|
|
48
|
+
return parts.length ? parts.join(" ") : "0s"
|
|
49
|
+
}
|
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.
|
|
4
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"clean": "rm -rf dist",
|
|
25
25
|
"build": "npm run clean && tsup && tsc --emitDeclarationOnly",
|
|
26
26
|
"dev": "opencode plugin dev",
|
|
27
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
27
28
|
"typecheck": "tsc --noEmit",
|
|
28
29
|
"format": "prettier --write .",
|
|
29
30
|
"format:check": "prettier --check ."
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"prettier": "^3.4.0",
|
|
60
61
|
"solid-js": "^1.9.12",
|
|
61
62
|
"tsup": "^8.5.1",
|
|
63
|
+
"tsx": "^4.23.15",
|
|
62
64
|
"typescript": "^5.7.0"
|
|
63
65
|
},
|
|
64
66
|
"files": [
|
package/lib/tui/commands.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
|
-
import { readControl, writeControl } from "../control"
|
|
3
|
-
|
|
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
|
-
|
|
10
|
-
const setEnabled = async (enabled: boolean) => {
|
|
11
|
-
try {
|
|
12
|
-
await writeControl(enabled, directory())
|
|
13
|
-
api.ui.toast({
|
|
14
|
-
variant: enabled ? "success" : "warning",
|
|
15
|
-
title: "Cache keepalive",
|
|
16
|
-
message: enabled ? "Enabled" : "Disabled",
|
|
17
|
-
})
|
|
18
|
-
} catch (error) {
|
|
19
|
-
api.ui.toast({
|
|
20
|
-
variant: "error",
|
|
21
|
-
title: "Cache keepalive",
|
|
22
|
-
message: error instanceof Error ? error.message : String(error),
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
api.keymap.registerLayer({
|
|
28
|
-
commands: [
|
|
29
|
-
{
|
|
30
|
-
name: "keepalive.toggle",
|
|
31
|
-
title: "Toggle cache keepalive",
|
|
32
|
-
category: "Keepalive",
|
|
33
|
-
namespace: "palette",
|
|
34
|
-
slashName: "keepalive-toggle",
|
|
35
|
-
run: () => setEnabled(!currentEnabled(directory())),
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: "keepalive.on",
|
|
39
|
-
title: "Enable cache keepalive",
|
|
40
|
-
category: "Keepalive",
|
|
41
|
-
namespace: "palette",
|
|
42
|
-
slashName: "keepalive-on",
|
|
43
|
-
run: () => setEnabled(true),
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
name: "keepalive.off",
|
|
47
|
-
title: "Disable cache keepalive",
|
|
48
|
-
category: "Keepalive",
|
|
49
|
-
namespace: "palette",
|
|
50
|
-
slashName: "keepalive-off",
|
|
51
|
-
run: () => setEnabled(false),
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function currentEnabled(directory: string): boolean {
|
|
58
|
-
return readControl(directory)?.enabled ?? true
|
|
59
|
-
}
|