@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.
- package/MIGRATION.md +57 -0
- package/README.md +75 -51
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1093 -649
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +8 -25
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/control.d.ts +10 -37
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/paths.d.ts +5 -12
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/replay-warmer.d.ts +95 -0
- package/dist/lib/replay-warmer.d.ts.map +1 -0
- package/dist/lib/state.d.ts +15 -117
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +1 -6
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts +5 -5
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/dist/lib/types.d.ts +123 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.d.ts.map +1 -1
- package/lib/config.ts +106 -74
- package/lib/control.ts +50 -86
- package/lib/paths.ts +12 -14
- package/lib/replay-warmer.ts +1200 -0
- package/lib/state.ts +171 -181
- package/lib/tui/commands.tsx +36 -144
- package/lib/tui/footer.tsx +54 -156
- package/lib/tui/format.ts +4 -0
- package/lib/types.ts +130 -0
- package/package.json +3 -2
- package/tui.tsx +40 -0
- package/dist/lib/keepalive.d.ts +0 -88
- package/dist/lib/keepalive.d.ts.map +0 -1
- package/dist/lib/model.d.ts +0 -8
- package/dist/lib/model.d.ts.map +0 -1
- package/dist/lib/system.d.ts +0 -11
- package/dist/lib/system.d.ts.map +0 -1
- package/lib/keepalive.ts +0 -665
- package/lib/model.ts +0 -19
- package/lib/system.ts +0 -19
package/lib/state.ts
CHANGED
|
@@ -1,211 +1,201 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
49
|
-
/** Live per-session bookkeeping held by the server plugin. */
|
|
50
|
-
export type SessionKeepalive = {
|
|
51
|
-
sessionID: string
|
|
52
|
-
/** Whether the session's provider/model pair supports cache warming. */
|
|
53
|
-
eligible: boolean
|
|
54
|
-
modelLabel?: string
|
|
55
|
-
/** Epoch ms of the last real assistant response (excludes ping replies). */
|
|
56
|
-
lastResponseAt: number
|
|
57
|
-
/** Epoch ms the current idle stretch began. */
|
|
58
|
-
idleSince: number
|
|
59
|
-
/** Epoch ms after which warming stops and the cache is allowed to go cold. */
|
|
60
|
-
windowEndsAt: number
|
|
61
|
-
/** Epoch ms the next ping is due. */
|
|
62
|
-
nextPingAt: number
|
|
63
|
-
/** Number of scheduled ping requests attempted during this warm window. */
|
|
64
|
-
pingsSent: number
|
|
65
|
-
/** A real turn is currently running. */
|
|
66
|
-
busy: boolean
|
|
67
|
-
/** A keepalive ping is currently in flight. */
|
|
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
|
|
76
|
-
/** Within the warm window and actively scheduling pings. */
|
|
77
|
-
active: boolean
|
|
78
|
-
/** True while `armWindow` is resolving session metadata (async guard). */
|
|
79
|
-
arming?: boolean
|
|
80
|
-
/** Epoch ms the last sent ping finished; ignores its late events and marks the last cache touch. */
|
|
81
|
-
lastPingAt?: number
|
|
82
|
-
lastPing?: PingRecord
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Lean projection persisted to disk for the TUI reader. */
|
|
86
|
-
export type PersistedSession = {
|
|
87
|
-
eligible: boolean
|
|
88
|
-
modelLabel?: string
|
|
89
|
-
lastResponseAt: number
|
|
90
|
-
idleSince: number
|
|
91
|
-
windowEndsAt: number
|
|
92
|
-
nextPingAt: number
|
|
93
|
-
intervalMs: number
|
|
94
|
-
pingsSent: number
|
|
95
|
-
busy: boolean
|
|
96
|
-
warming: boolean
|
|
97
|
-
active: boolean
|
|
98
|
-
lastPing?: PingRecord
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export type PersistedState = {
|
|
102
|
-
version: 1
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { readdirSync, readFileSync, unlinkSync } from "node:fs"
|
|
3
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import type { Logger } from "./logger"
|
|
6
|
+
import { instanceStateFilePath, stateDirectoryKey, stateDirectoryPath } from "./paths"
|
|
7
|
+
import type { ProcessTotals, SessionWarmEntry, WarmStateSink } from "./types"
|
|
8
|
+
|
|
9
|
+
const CLEANUP_INTERVAL_MS = 10 * 60_000
|
|
10
|
+
const PERSIST_DEBOUNCE_MS = 250
|
|
11
|
+
|
|
12
|
+
type StateFile = {
|
|
13
|
+
version: 2
|
|
14
|
+
pid: number
|
|
15
|
+
instanceId: string
|
|
103
16
|
updatedAt: number
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
windowMs: number
|
|
107
|
-
sessions: Record<string, PersistedSession>
|
|
17
|
+
sessions: Record<string, SessionWarmEntry>
|
|
18
|
+
totals: ProcessTotals
|
|
108
19
|
}
|
|
109
20
|
|
|
110
|
-
export
|
|
111
|
-
private readonly sessions = new Map<string, SessionKeepalive>()
|
|
112
|
-
private writeQueue: Promise<void> = Promise.resolve()
|
|
113
|
-
private enabled: boolean
|
|
114
|
-
private intervalMs: number
|
|
21
|
+
export type StateStoreOptions = { logger?: Logger; now?: () => number }
|
|
115
22
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Create an isolated v2 snapshot writer. Mutations are debounced, dispose flushes
|
|
25
|
+
* the latest snapshot before removing it, and all filesystem failures stay
|
|
26
|
+
* best-effort so state reporting cannot disrupt replay execution.
|
|
27
|
+
*/
|
|
28
|
+
export function createStateStore(
|
|
29
|
+
directory: string,
|
|
30
|
+
options: StateStoreOptions = {},
|
|
31
|
+
): WarmStateSink {
|
|
32
|
+
const now = options.now ?? Date.now
|
|
33
|
+
const instanceId = randomUUID()
|
|
34
|
+
const filePath = instanceStateFilePath(directory, process.pid, instanceId)
|
|
35
|
+
const sessions = new Map<string, SessionWarmEntry>()
|
|
36
|
+
let totals: ProcessTotals = {
|
|
37
|
+
replays: 0,
|
|
38
|
+
avoidedTokens: 0,
|
|
39
|
+
replayReadTokens: 0,
|
|
40
|
+
resumeHits: 0,
|
|
41
|
+
resumeMisses: 0,
|
|
122
42
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
43
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
let cleanupTimer: ReturnType<typeof setInterval> | undefined
|
|
45
|
+
let writeQueue: Promise<void> = Promise.resolve()
|
|
46
|
+
let disposed = false
|
|
47
|
+
|
|
48
|
+
const flush = (): Promise<void> => {
|
|
49
|
+
if (timer) clearTimeout(timer)
|
|
50
|
+
timer = undefined
|
|
51
|
+
const snapshot: StateFile = {
|
|
52
|
+
version: 2,
|
|
53
|
+
pid: process.pid,
|
|
54
|
+
instanceId,
|
|
55
|
+
updatedAt: now(),
|
|
56
|
+
sessions: Object.fromEntries(sessions),
|
|
57
|
+
totals,
|
|
58
|
+
}
|
|
59
|
+
const write = () => atomicWrite(filePath, JSON.stringify(snapshot))
|
|
60
|
+
writeQueue = writeQueue.then(write, write)
|
|
61
|
+
return writeQueue
|
|
126
62
|
}
|
|
127
63
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
64
|
+
const schedule = () => {
|
|
65
|
+
if (disposed || timer) return
|
|
66
|
+
timer = setTimeout(() => {
|
|
67
|
+
timer = undefined
|
|
68
|
+
void flush()
|
|
69
|
+
}, PERSIST_DEBOUNCE_MS)
|
|
131
70
|
}
|
|
132
71
|
|
|
133
|
-
|
|
134
|
-
|
|
72
|
+
const onExit = () => {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(filePath)
|
|
75
|
+
} catch {
|
|
76
|
+
// Exit cleanup is best-effort.
|
|
77
|
+
}
|
|
135
78
|
}
|
|
136
79
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
80
|
+
cleanupStateFiles(directory, options.logger)
|
|
81
|
+
cleanupTimer = setInterval(
|
|
82
|
+
() => cleanupStateFiles(directory, options.logger),
|
|
83
|
+
CLEANUP_INTERVAL_MS,
|
|
84
|
+
)
|
|
85
|
+
process.on("exit", onExit)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
upsert(entry) {
|
|
89
|
+
if (disposed) return
|
|
90
|
+
sessions.set(entry.sessionID, { ...entry })
|
|
91
|
+
schedule()
|
|
92
|
+
},
|
|
93
|
+
remove(sessionID) {
|
|
94
|
+
if (disposed) return
|
|
95
|
+
sessions.delete(sessionID)
|
|
96
|
+
schedule()
|
|
97
|
+
},
|
|
98
|
+
setTotals(value) {
|
|
99
|
+
if (disposed) return
|
|
100
|
+
totals = { ...value }
|
|
101
|
+
schedule()
|
|
102
|
+
},
|
|
103
|
+
async dispose() {
|
|
104
|
+
if (disposed) return
|
|
105
|
+
disposed = true
|
|
106
|
+
if (timer) clearTimeout(timer)
|
|
107
|
+
timer = undefined
|
|
108
|
+
if (cleanupTimer) clearInterval(cleanupTimer)
|
|
109
|
+
cleanupTimer = undefined
|
|
110
|
+
process.off("exit", onExit)
|
|
111
|
+
await flush()
|
|
112
|
+
await unlink(filePath).catch(() => {})
|
|
113
|
+
},
|
|
154
114
|
}
|
|
115
|
+
}
|
|
155
116
|
|
|
156
|
-
|
|
157
|
-
|
|
117
|
+
/** Return the newest self-contained entry for a session across matching v2 files. */
|
|
118
|
+
export function readSessionEntry(
|
|
119
|
+
directory: string,
|
|
120
|
+
sessionID: string,
|
|
121
|
+
): SessionWarmEntry | undefined {
|
|
122
|
+
let freshest: SessionWarmEntry | undefined
|
|
123
|
+
for (const snapshot of readSnapshots(directory)) {
|
|
124
|
+
const entry = snapshot.sessions[sessionID]
|
|
125
|
+
if (entry && (!freshest || entry.updatedAt > freshest.updatedAt)) freshest = entry
|
|
158
126
|
}
|
|
127
|
+
return freshest
|
|
128
|
+
}
|
|
159
129
|
|
|
160
|
-
|
|
161
|
-
|
|
130
|
+
/** Sum process-level totals from live snapshots for this directory. */
|
|
131
|
+
export function readProcessTotals(directory: string): ProcessTotals {
|
|
132
|
+
const totals = {
|
|
133
|
+
replays: 0,
|
|
134
|
+
avoidedTokens: 0,
|
|
135
|
+
replayReadTokens: 0,
|
|
136
|
+
resumeHits: 0,
|
|
137
|
+
resumeMisses: 0,
|
|
138
|
+
}
|
|
139
|
+
for (const snapshot of readSnapshots(directory)) {
|
|
140
|
+
totals.replays += snapshot.totals.replays
|
|
141
|
+
totals.avoidedTokens += snapshot.totals.avoidedTokens
|
|
142
|
+
totals.replayReadTokens += snapshot.totals.replayReadTokens
|
|
143
|
+
totals.resumeHits += snapshot.totals.resumeHits
|
|
144
|
+
totals.resumeMisses += snapshot.totals.resumeMisses
|
|
162
145
|
}
|
|
146
|
+
return totals
|
|
147
|
+
}
|
|
163
148
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
modelLabel: s.modelLabel,
|
|
181
|
-
lastResponseAt: s.lastResponseAt,
|
|
182
|
-
idleSince: s.idleSince,
|
|
183
|
-
windowEndsAt: s.windowEndsAt,
|
|
184
|
-
nextPingAt: s.nextPingAt,
|
|
185
|
-
intervalMs: this.intervalMs,
|
|
186
|
-
pingsSent: s.pingsSent,
|
|
187
|
-
busy: s.busy,
|
|
188
|
-
warming: s.warming,
|
|
189
|
-
active: s.active,
|
|
190
|
-
lastPing: s.lastPing,
|
|
191
|
-
} satisfies PersistedSession,
|
|
192
|
-
]),
|
|
193
|
-
),
|
|
149
|
+
/** Remove only v2 snapshots whose owner PID is confirmed dead. */
|
|
150
|
+
export function cleanupStateFiles(directory: string, logger?: Logger): void {
|
|
151
|
+
try {
|
|
152
|
+
const prefix = `state-v2-${stateDirectoryKey(directory)}-`
|
|
153
|
+
for (const name of readdirSync(stateDirectoryPath())) {
|
|
154
|
+
const match = name.match(new RegExp(`^${prefix}(\\d+)-[^/]+\\.json$`))
|
|
155
|
+
if (!match) continue
|
|
156
|
+
const pid = Number(match[1])
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
|
161
|
+
unlinkSync(join(stateDirectoryPath(), name))
|
|
162
|
+
logger?.dbg("Removed stale state snapshot", { pid })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
194
165
|
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Storage can be unavailable during startup/shutdown; cleanup is optional.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
195
170
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
171
|
+
function readSnapshots(directory: string): StateFile[] {
|
|
172
|
+
const prefix = `state-v2-${stateDirectoryKey(directory)}-`
|
|
173
|
+
try {
|
|
174
|
+
return readdirSync(stateDirectoryPath())
|
|
175
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith(".json"))
|
|
176
|
+
.map((name) => {
|
|
177
|
+
try {
|
|
178
|
+
const value = JSON.parse(
|
|
179
|
+
readFileSync(join(stateDirectoryPath(), name), "utf8"),
|
|
180
|
+
) as StateFile
|
|
181
|
+
return value.version === 2 && value.sessions && value.totals ? value : undefined
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
.filter((value): value is StateFile => value !== undefined)
|
|
187
|
+
} catch {
|
|
188
|
+
return []
|
|
199
189
|
}
|
|
200
190
|
}
|
|
201
191
|
|
|
202
192
|
async function atomicWrite(path: string, contents: string): Promise<void> {
|
|
203
193
|
try {
|
|
204
|
-
const tmp = `${path}.${process.pid}.${
|
|
194
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
205
195
|
await mkdir(dirname(path), { recursive: true })
|
|
206
196
|
await writeFile(tmp, `${contents}\n`, "utf8")
|
|
207
197
|
await rename(tmp, path)
|
|
208
198
|
} catch {
|
|
209
|
-
//
|
|
199
|
+
// Best-effort persistence; stale display data is safer than disrupting replay.
|
|
210
200
|
}
|
|
211
201
|
}
|
package/lib/tui/commands.tsx
CHANGED
|
@@ -1,184 +1,76 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
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"
|
|
3
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import { readSessionControl, setSessionEnabled } from "../control"
|
|
15
5
|
|
|
16
|
-
/**
|
|
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
|
-
*/
|
|
6
|
+
/** Register runtime enable/disable commands for the current session only. */
|
|
30
7
|
export function registerKeepaliveCommands(api: TuiPluginApi): void {
|
|
31
|
-
// Read lazily: state paths may not be populated at registration time.
|
|
32
8
|
const directory = () => api.state.path.directory
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
9
|
+
const sessionID = () => {
|
|
10
|
+
const route = api.route.current
|
|
11
|
+
return route.name === "session" && route.params && "sessionID" in route.params
|
|
12
|
+
? String(route.params.sessionID)
|
|
13
|
+
: undefined
|
|
14
|
+
}
|
|
15
|
+
const setEnabled = async (enabled: boolean, id: string) => {
|
|
39
16
|
try {
|
|
40
|
-
await
|
|
41
|
-
api.ui.toast({
|
|
17
|
+
await setSessionEnabled(directory(), id, enabled)
|
|
18
|
+
api.ui.toast({
|
|
19
|
+
variant: enabled ? "success" : "warning",
|
|
20
|
+
title: "Keepalive",
|
|
21
|
+
message: enabled
|
|
22
|
+
? "Keepalive on for this session"
|
|
23
|
+
: "Keepalive off for this session",
|
|
24
|
+
})
|
|
42
25
|
} catch (error) {
|
|
43
26
|
api.ui.toast({
|
|
44
27
|
variant: "error",
|
|
45
|
-
title: "
|
|
28
|
+
title: "Keepalive",
|
|
46
29
|
message: error instanceof Error ? error.message : String(error),
|
|
47
30
|
})
|
|
48
31
|
}
|
|
49
32
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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",
|
|
33
|
+
const run = (operation: (id: string) => void | Promise<void>) => () => {
|
|
34
|
+
const id = sessionID()
|
|
35
|
+
if (!id) {
|
|
36
|
+
api.ui.toast({
|
|
37
|
+
variant: "warning",
|
|
38
|
+
title: "Keepalive",
|
|
39
|
+
message: "Open a session to toggle keepalive",
|
|
99
40
|
})
|
|
41
|
+
return
|
|
100
42
|
}
|
|
101
|
-
|
|
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
|
-
))
|
|
43
|
+
return operation(id)
|
|
130
44
|
}
|
|
131
45
|
|
|
132
46
|
api.keymap.registerLayer({
|
|
133
47
|
commands: [
|
|
134
48
|
{
|
|
135
49
|
name: "keepalive.toggle",
|
|
136
|
-
title: "Toggle cache
|
|
50
|
+
title: "Toggle replay cache warmer",
|
|
137
51
|
category: "Keepalive",
|
|
138
52
|
namespace: "palette",
|
|
139
53
|
slashName: "keepalive-toggle",
|
|
140
|
-
run: () =>
|
|
54
|
+
run: run((id) =>
|
|
55
|
+
setEnabled(!(readSessionControl(directory(), id)?.enabled ?? true), id),
|
|
56
|
+
),
|
|
141
57
|
},
|
|
142
58
|
{
|
|
143
59
|
name: "keepalive.on",
|
|
144
|
-
title: "Enable cache
|
|
60
|
+
title: "Enable replay cache warmer",
|
|
145
61
|
category: "Keepalive",
|
|
146
62
|
namespace: "palette",
|
|
147
63
|
slashName: "keepalive-on",
|
|
148
|
-
run: () => setEnabled(true),
|
|
64
|
+
run: run((id) => setEnabled(true, id)),
|
|
149
65
|
},
|
|
150
66
|
{
|
|
151
67
|
name: "keepalive.off",
|
|
152
|
-
title: "Disable cache
|
|
68
|
+
title: "Disable replay cache warmer",
|
|
153
69
|
category: "Keepalive",
|
|
154
70
|
namespace: "palette",
|
|
155
71
|
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,
|
|
72
|
+
run: run((id) => setEnabled(false, id)),
|
|
165
73
|
},
|
|
166
74
|
],
|
|
167
75
|
})
|
|
168
76
|
}
|
|
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
|
-
}
|