@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.
Files changed (46) hide show
  1. package/MIGRATION.md +57 -0
  2. package/README.md +75 -48
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1097 -614
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config.d.ts +8 -25
  7. package/dist/lib/config.d.ts.map +1 -1
  8. package/dist/lib/control.d.ts +10 -4
  9. package/dist/lib/control.d.ts.map +1 -1
  10. package/dist/lib/paths.d.ts +5 -12
  11. package/dist/lib/paths.d.ts.map +1 -1
  12. package/dist/lib/replay-warmer.d.ts +95 -0
  13. package/dist/lib/replay-warmer.d.ts.map +1 -0
  14. package/dist/lib/state.d.ts +15 -114
  15. package/dist/lib/state.d.ts.map +1 -1
  16. package/dist/lib/tui/commands.d.ts +2 -0
  17. package/dist/lib/tui/commands.d.ts.map +1 -1
  18. package/dist/lib/tui/footer.d.ts +5 -5
  19. package/dist/lib/tui/footer.d.ts.map +1 -1
  20. package/dist/lib/tui/format.d.ts +8 -0
  21. package/dist/lib/tui/format.d.ts.map +1 -1
  22. package/dist/lib/types.d.ts +123 -0
  23. package/dist/lib/types.d.ts.map +1 -0
  24. package/dist/tui.d.ts +3 -0
  25. package/dist/tui.d.ts.map +1 -1
  26. package/lib/config.ts +106 -74
  27. package/lib/control.ts +69 -20
  28. package/lib/paths.ts +12 -14
  29. package/lib/replay-warmer.ts +1200 -0
  30. package/lib/state.ts +172 -175
  31. package/lib/tui/commands.tsx +76 -0
  32. package/lib/tui/footer.tsx +55 -130
  33. package/lib/tui/format.ts +40 -0
  34. package/lib/types.ts +130 -0
  35. package/package.json +3 -2
  36. package/tui.tsx +40 -0
  37. package/dist/lib/keepalive.d.ts +0 -85
  38. package/dist/lib/keepalive.d.ts.map +0 -1
  39. package/dist/lib/model.d.ts +0 -8
  40. package/dist/lib/model.d.ts.map +0 -1
  41. package/dist/lib/system.d.ts +0 -11
  42. package/dist/lib/system.d.ts.map +0 -1
  43. package/lib/keepalive.ts +0 -636
  44. package/lib/model.ts +0 -19
  45. package/lib/system.ts +0 -19
  46. package/lib/tui/commands.ts +0 -59
package/lib/state.ts CHANGED
@@ -1,204 +1,201 @@
1
- import { mkdir, rename, writeFile } from "node:fs/promises"
2
- import { dirname } from "node:path"
3
- import type { KeepaliveConfig } from "./config"
4
- import { stateFilePath } from "./paths"
5
-
6
- export type PingRecord = {
7
- at: number
8
- hit: boolean
9
- input: number
10
- cacheRead: number
11
- cacheWrite: number
12
- output: number
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
- enabled: boolean
105
- intervalMs: number
106
- windowMs: number
107
- sessions: Record<string, PersistedSession>
17
+ sessions: Record<string, SessionWarmEntry>
18
+ totals: ProcessTotals
108
19
  }
109
20
 
110
- export class KeepaliveStore {
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
- constructor(
116
- private readonly config: KeepaliveConfig,
117
- private readonly directory: string,
118
- ) {
119
- this.enabled = config.enabled
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
- setEnabled(enabled: boolean): void {
123
- this.enabled = enabled
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
- get(sessionID: string): SessionKeepalive | undefined {
127
- return this.sessions.get(sessionID)
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
- ensure(sessionID: string): SessionKeepalive {
131
- const existing = this.sessions.get(sessionID)
132
- if (existing) return existing
133
- const created: SessionKeepalive = {
134
- sessionID,
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
- remove(sessionID: string): void {
150
- this.sessions.delete(sessionID)
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
- all(): SessionKeepalive[] {
154
- return [...this.sessions.values()]
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
- * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
159
- * returned promise resolves once this snapshot and every earlier one are written.
160
- */
161
- persist(): Promise<void> {
162
- const snapshot: PersistedState = {
163
- version: 1,
164
- updatedAt: Date.now(),
165
- enabled: this.enabled,
166
- intervalMs: this.config.intervalMs,
167
- windowMs: this.config.windowMs,
168
- sessions: Object.fromEntries(
169
- this.all().map((s) => [
170
- s.sessionID,
171
- {
172
- eligible: s.eligible,
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
- const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
190
- this.writeQueue = this.writeQueue.then(write, write)
191
- return this.writeQueue
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}.${Date.now()}.tmp`
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
- // Persistence is best-effort; the TUI readout simply falls behind.
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
+ }
@@ -2,51 +2,19 @@
2
2
 
3
3
  import { TextAttributes } from "@opentui/core"
4
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"
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 [state, setState] = createSignal<PersistedState | undefined>(undefined)
14
+ const [entry, setEntry] = createSignal<SessionWarmEntry | undefined>()
29
15
 
30
16
  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
- .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={derived()}>
94
- {(data: () => Derived) => (
95
- <box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} gap={0}>
96
- <box flexDirection="row" gap={1}>
97
- <text fg={theme.primary} attributes={TextAttributes.BOLD}>
98
- keepalive
99
- </text>
100
- <text fg={statusColor(theme, data().status)}>
101
- {statusLabel(data().status)}
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
- <box flexDirection="row" gap={1}>
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
- function statusLabel(status: Status): string {
140
- if (status === "waiting") return "waiting"
141
- if (status === "warming") return "warming…"
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 statusColor(theme: Theme, status: Status): any {
149
- if (status === "waiting") return theme.textMuted
150
- if (status === "warming") return theme.accent
151
- if (status === "busy") return theme.info
152
- if (status === "armed") return theme.success
153
- if (status === "cold") return theme.warning
154
- return theme.textMuted
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 readState(path: string): PersistedState | undefined {
158
- try {
159
- return JSON.parse(readFileSync(path, "utf8")) as PersistedState
160
- } catch {
161
- return undefined
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
  }