@vikrant82/opencode-cache-keepalive 0.1.5 → 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 -48
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1097 -614
- 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 -4
- 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 -114
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +2 -0
- 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 +8 -0
- 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 +69 -20
- package/lib/paths.ts +12 -14
- package/lib/replay-warmer.ts +1200 -0
- package/lib/state.ts +172 -175
- package/lib/tui/commands.tsx +76 -0
- package/lib/tui/footer.tsx +55 -130
- package/lib/tui/format.ts +40 -0
- package/lib/types.ts +130 -0
- package/package.json +3 -2
- package/tui.tsx +40 -0
- package/dist/lib/keepalive.d.ts +0 -85
- 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 -636
- package/lib/model.ts +0 -19
- package/lib/system.ts +0 -19
- package/lib/tui/commands.ts +0 -59
package/lib/state.ts
CHANGED
|
@@ -1,204 +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
|
|
21
|
+
export type StateStoreOptions = { logger?: Logger; now?: () => number }
|
|
114
22
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,
|
|
120
42
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
62
|
}
|
|
125
63
|
|
|
126
|
-
|
|
127
|
-
|
|
64
|
+
const schedule = () => {
|
|
65
|
+
if (disposed || timer) return
|
|
66
|
+
timer = setTimeout(() => {
|
|
67
|
+
timer = undefined
|
|
68
|
+
void flush()
|
|
69
|
+
}, PERSIST_DEBOUNCE_MS)
|
|
128
70
|
}
|
|
129
71
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
eligible: false,
|
|
136
|
-
lastResponseAt: 0,
|
|
137
|
-
idleSince: 0,
|
|
138
|
-
windowEndsAt: 0,
|
|
139
|
-
nextPingAt: 0,
|
|
140
|
-
pingsSent: 0,
|
|
141
|
-
busy: false,
|
|
142
|
-
warming: false,
|
|
143
|
-
active: false,
|
|
72
|
+
const onExit = () => {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(filePath)
|
|
75
|
+
} catch {
|
|
76
|
+
// Exit cleanup is best-effort.
|
|
144
77
|
}
|
|
145
|
-
this.sessions.set(sessionID, created)
|
|
146
|
-
return created
|
|
147
78
|
}
|
|
148
79
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
},
|
|
151
114
|
}
|
|
115
|
+
}
|
|
152
116
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
126
|
+
}
|
|
127
|
+
return freshest
|
|
128
|
+
}
|
|
129
|
+
|
|
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
|
|
155
145
|
}
|
|
146
|
+
return totals
|
|
147
|
+
}
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
modelLabel: s.modelLabel,
|
|
174
|
-
lastResponseAt: s.lastResponseAt,
|
|
175
|
-
idleSince: s.idleSince,
|
|
176
|
-
windowEndsAt: s.windowEndsAt,
|
|
177
|
-
nextPingAt: s.nextPingAt,
|
|
178
|
-
intervalMs: this.config.intervalMs,
|
|
179
|
-
pingsSent: s.pingsSent,
|
|
180
|
-
busy: s.busy,
|
|
181
|
-
warming: s.warming,
|
|
182
|
-
active: s.active,
|
|
183
|
-
lastPing: s.lastPing,
|
|
184
|
-
} satisfies PersistedSession,
|
|
185
|
-
]),
|
|
186
|
-
),
|
|
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
|
+
}
|
|
187
165
|
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Storage can be unavailable during startup/shutdown; cleanup is optional.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
188
170
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 []
|
|
192
189
|
}
|
|
193
190
|
}
|
|
194
191
|
|
|
195
192
|
async function atomicWrite(path: string, contents: string): Promise<void> {
|
|
196
193
|
try {
|
|
197
|
-
const tmp = `${path}.${process.pid}.${
|
|
194
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
198
195
|
await mkdir(dirname(path), { recursive: true })
|
|
199
196
|
await writeFile(tmp, `${contents}\n`, "utf8")
|
|
200
197
|
await rename(tmp, path)
|
|
201
198
|
} catch {
|
|
202
|
-
//
|
|
199
|
+
// Best-effort persistence; stale display data is safer than disrupting replay.
|
|
203
200
|
}
|
|
204
201
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import { readSessionControl, setSessionEnabled } from "../control"
|
|
5
|
+
|
|
6
|
+
/** Register runtime enable/disable commands for the current session only. */
|
|
7
|
+
export function registerKeepaliveCommands(api: TuiPluginApi): void {
|
|
8
|
+
const directory = () => api.state.path.directory
|
|
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) => {
|
|
16
|
+
try {
|
|
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
|
+
})
|
|
25
|
+
} catch (error) {
|
|
26
|
+
api.ui.toast({
|
|
27
|
+
variant: "error",
|
|
28
|
+
title: "Keepalive",
|
|
29
|
+
message: error instanceof Error ? error.message : String(error),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
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",
|
|
40
|
+
})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
return operation(id)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
api.keymap.registerLayer({
|
|
47
|
+
commands: [
|
|
48
|
+
{
|
|
49
|
+
name: "keepalive.toggle",
|
|
50
|
+
title: "Toggle replay cache warmer",
|
|
51
|
+
category: "Keepalive",
|
|
52
|
+
namespace: "palette",
|
|
53
|
+
slashName: "keepalive-toggle",
|
|
54
|
+
run: run((id) =>
|
|
55
|
+
setEnabled(!(readSessionControl(directory(), id)?.enabled ?? true), id),
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "keepalive.on",
|
|
60
|
+
title: "Enable replay cache warmer",
|
|
61
|
+
category: "Keepalive",
|
|
62
|
+
namespace: "palette",
|
|
63
|
+
slashName: "keepalive-on",
|
|
64
|
+
run: run((id) => setEnabled(true, id)),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "keepalive.off",
|
|
68
|
+
title: "Disable replay cache warmer",
|
|
69
|
+
category: "Keepalive",
|
|
70
|
+
namespace: "palette",
|
|
71
|
+
slashName: "keepalive-off",
|
|
72
|
+
run: run((id) => setEnabled(false, id)),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
})
|
|
76
|
+
}
|
package/lib/tui/footer.tsx
CHANGED
|
@@ -2,51 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { TextAttributes } from "@opentui/core"
|
|
4
4
|
import { createSignal, onCleanup, Show } from "solid-js"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { stateDirectoryPath, stateFilePath } from "../paths"
|
|
8
|
-
import type { PersistedSession, PersistedState, PingRecord } from "../state"
|
|
5
|
+
import type { SessionWarmEntry, WarmState } from "../types"
|
|
6
|
+
import { readSessionEntry } from "../state"
|
|
9
7
|
import { kfmt, mmss } from "./format"
|
|
10
8
|
|
|
11
9
|
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
10
|
|
|
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
|
-
*/
|
|
11
|
+
/** Display the persisted replay-warming state for the current session only. */
|
|
26
12
|
export function KeepaliveFooter(props: { theme: Theme; directory: string; sessionID?: string }) {
|
|
27
13
|
const [now, setNow] = createSignal(Date.now())
|
|
28
|
-
const [
|
|
14
|
+
const [entry, setEntry] = createSignal<SessionWarmEntry | undefined>()
|
|
29
15
|
|
|
30
16
|
const load = () => {
|
|
31
|
-
|
|
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
|
-
.filter(
|
|
42
|
-
(snapshot): snapshot is PersistedState =>
|
|
43
|
-
snapshot?.sessions?.[sessionID] !== undefined,
|
|
44
|
-
)
|
|
45
|
-
.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
|
46
|
-
setState(match)
|
|
47
|
-
} catch {
|
|
48
|
-
setState(undefined)
|
|
49
|
-
}
|
|
17
|
+
setEntry(props.sessionID ? readSessionEntry(props.directory, props.sessionID) : undefined)
|
|
50
18
|
}
|
|
51
19
|
|
|
52
20
|
load()
|
|
@@ -56,108 +24,65 @@ export function KeepaliveFooter(props: { theme: Theme; directory: string; sessio
|
|
|
56
24
|
}, 1000)
|
|
57
25
|
onCleanup(() => clearInterval(timer))
|
|
58
26
|
|
|
59
|
-
const derived = (): Derived | undefined => {
|
|
60
|
-
const snapshot = state()
|
|
61
|
-
const sessionID = props.sessionID
|
|
62
|
-
if (!snapshot || !sessionID) {
|
|
63
|
-
return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
|
|
64
|
-
}
|
|
65
|
-
const session: PersistedSession | undefined = snapshot.sessions?.[sessionID]
|
|
66
|
-
if (!session) return { idleMs: 0, sent: 0, left: 0, status: "waiting" }
|
|
67
|
-
|
|
68
|
-
const interval = session.intervalMs || snapshot.intervalMs || 270_000
|
|
69
|
-
const idleMs = Math.max(0, now() - (session.lastResponseAt || now()))
|
|
70
|
-
// `nextPingAt` is the scheduler's source of truth and includes the
|
|
71
|
-
// per-ping jitter. Counting from it keeps the two counters in step.
|
|
72
|
-
const nextPingAt = Math.max(session.nextPingAt || now(), now())
|
|
73
|
-
const left = session.active
|
|
74
|
-
? Math.max(0, Math.ceil((session.windowEndsAt - nextPingAt) / interval))
|
|
75
|
-
: 0
|
|
76
|
-
const status: Status =
|
|
77
|
-
!snapshot.enabled || !session.eligible
|
|
78
|
-
? "off"
|
|
79
|
-
: session.warming
|
|
80
|
-
? "warming"
|
|
81
|
-
: session.busy
|
|
82
|
-
? "busy"
|
|
83
|
-
: session.active
|
|
84
|
-
? "armed"
|
|
85
|
-
: "cold"
|
|
86
|
-
|
|
87
|
-
return { idleMs, sent: session.pingsSent || 0, left, status, lastPing: session.lastPing }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const theme = props.theme
|
|
91
|
-
|
|
92
27
|
return (
|
|
93
|
-
<Show when={
|
|
94
|
-
{(data: () =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
28
|
+
<Show when={entry()}>
|
|
29
|
+
{(data: () => SessionWarmEntry) => {
|
|
30
|
+
const firstLine = () => {
|
|
31
|
+
const value = data()
|
|
32
|
+
const remaining =
|
|
33
|
+
value.nextReplayAt === null ? 0 : Math.max(0, value.nextReplayAt - now())
|
|
34
|
+
return formatStatusLine(value, remaining)
|
|
35
|
+
}
|
|
36
|
+
const secondLine = () => formatDetails(data(), now())
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<box
|
|
40
|
+
flexDirection="column"
|
|
41
|
+
paddingLeft={1}
|
|
42
|
+
paddingRight={1}
|
|
43
|
+
paddingTop={1}
|
|
44
|
+
gap={0}
|
|
45
|
+
>
|
|
46
|
+
<text
|
|
47
|
+
fg={stateColor(props.theme, data().state)}
|
|
48
|
+
attributes={TextAttributes.BOLD}
|
|
49
|
+
>
|
|
50
|
+
{firstLine()}
|
|
102
51
|
</text>
|
|
52
|
+
<Show when={secondLine()}>
|
|
53
|
+
{(line: () => string) => (
|
|
54
|
+
<text fg={props.theme.textMuted}>{line()}</text>
|
|
55
|
+
)}
|
|
56
|
+
</Show>
|
|
103
57
|
</box>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<text fg={theme.textMuted}>idle</text>
|
|
107
|
-
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
108
|
-
{mmss(data().idleMs)}
|
|
109
|
-
</text>
|
|
110
|
-
<text fg={theme.textMuted}>· sent</text>
|
|
111
|
-
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
112
|
-
{String(data().sent)}
|
|
113
|
-
</text>
|
|
114
|
-
<text fg={theme.textMuted}>· left</text>
|
|
115
|
-
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
|
116
|
-
{String(data().left)}
|
|
117
|
-
</text>
|
|
118
|
-
</box>
|
|
119
|
-
|
|
120
|
-
<Show when={data().lastPing}>
|
|
121
|
-
{(ping: () => PingRecord) => (
|
|
122
|
-
<box flexDirection="row" gap={1}>
|
|
123
|
-
<text fg={ping().hit ? theme.success : theme.warning}>
|
|
124
|
-
{ping().hit ? "✓ hit" : "✗ miss"}
|
|
125
|
-
</text>
|
|
126
|
-
<text fg={theme.textMuted}>
|
|
127
|
-
input {kfmt(ping().input)} · read {kfmt(ping().cacheRead)} ·
|
|
128
|
-
write {kfmt(ping().cacheWrite)}
|
|
129
|
-
</text>
|
|
130
|
-
</box>
|
|
131
|
-
)}
|
|
132
|
-
</Show>
|
|
133
|
-
</box>
|
|
134
|
-
)}
|
|
58
|
+
)
|
|
59
|
+
}}
|
|
135
60
|
</Show>
|
|
136
61
|
)
|
|
137
62
|
}
|
|
138
63
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (status === "busy") return "busy"
|
|
143
|
-
if (status === "armed") return "armed"
|
|
144
|
-
if (status === "cold") return "cold"
|
|
145
|
-
return "off"
|
|
64
|
+
/** Pure formatter for the two-line footer's optional telemetry. */
|
|
65
|
+
export function formatDetails(entry: SessionWarmEntry, now = Date.now()): string | undefined {
|
|
66
|
+
return `refreshes ${entry.sessionReplays} · hits ${entry.resumeHits}/${entry.resumeCount} · saved ~${kfmt(entry.avoidedTokens)} tok`
|
|
146
67
|
}
|
|
147
68
|
|
|
148
|
-
function
|
|
149
|
-
if (
|
|
150
|
-
if (
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
69
|
+
export function formatStatusLine(entry: SessionWarmEntry, remainingMs: number): string {
|
|
70
|
+
if (entry.state === "off") return "keepalive off"
|
|
71
|
+
if (entry.state === "active") return "keepalive standby · model working"
|
|
72
|
+
if (entry.state === "stopped") {
|
|
73
|
+
if (entry.stopReason === "cap") return `keepalive stopped · refresh cap (${entry.cap})`
|
|
74
|
+
if (entry.stopReason === "lapsed") return "keepalive stopped · cache expired"
|
|
75
|
+
if (entry.stopReason === "rejected-4xx")
|
|
76
|
+
return `keepalive stopped · refresh rejected ${entry.stopStatus ?? "unknown"}`
|
|
77
|
+
return "keepalive off"
|
|
78
|
+
}
|
|
79
|
+
const state = entry.state
|
|
80
|
+
return `keepalive ${state} · ${entry.gapReplays}/${entry.cap}${entry.nextReplayAt === null ? "" : ` · next ${mmss(remainingMs)}`}`
|
|
155
81
|
}
|
|
156
82
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
83
|
+
function stateColor(theme: Theme, state: WarmState): any {
|
|
84
|
+
if (state === "busy" || state === "active") return theme.info
|
|
85
|
+
if (state === "idle") return theme.success
|
|
86
|
+
if (state === "stopped") return theme.warning
|
|
87
|
+
return theme.textMuted
|
|
163
88
|
}
|