pi-kimi-keepalive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/README.zh-CN.md +118 -0
- package/package.json +46 -0
- package/src/index.ts +844 -0
- package/src/lib.ts +245 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-kimi-keepalive — keeps the Kimi (provider "kimi-coding") automatic
|
|
3
|
+
* prompt cache warm during long idle gaps in Pi sessions.
|
|
4
|
+
*
|
|
5
|
+
* Mechanism:
|
|
6
|
+
* 1. Every real provider request is observed read-only via the
|
|
7
|
+
* `before_provider_headers` / `before_provider_request` hooks. The full
|
|
8
|
+
* payload (system/messages/tools, including any cache_control markers)
|
|
9
|
+
* and its auth headers are captured.
|
|
10
|
+
* 2. While the session sits idle, the captured request is replayed as a
|
|
11
|
+
* small non-streaming call: `max_tokens` clamped, `thinking` removed,
|
|
12
|
+
* `stream` removed, `tool_choice: none` added (with a one-shot fallback
|
|
13
|
+
* if the endpoint rejects it). The conversation prefix is untouched, so
|
|
14
|
+
* the provider's automatic prefix cache sees it as a continuation and
|
|
15
|
+
* restarts the cache TTL at cache-read prices.
|
|
16
|
+
* 3. Probes never enter the Pi session: no synthetic user messages, no
|
|
17
|
+
* synthetic tool runs, no model output kept anywhere. Only aggregate
|
|
18
|
+
* probe statistics (hits / misses / estimated savings) are surfaced.
|
|
19
|
+
*
|
|
20
|
+
* Guardrails: probes are skipped while the agent is busy, stop after
|
|
21
|
+
* `maxidle`, after repeated cache misses, on auth failures, or when the
|
|
22
|
+
* session spend cap is reached. A fresh real request always re-arms and
|
|
23
|
+
* clears sticky pauses.
|
|
24
|
+
*
|
|
25
|
+
* This depends on Kimi's automatic context caching. The cache TTL is not a
|
|
26
|
+
* contractual guarantee of the provider; treat this extension as an
|
|
27
|
+
* experiment with guardrails, not a savings promise.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import {
|
|
35
|
+
buildProbeBody,
|
|
36
|
+
buildProbeHeaders,
|
|
37
|
+
estimateSavedUsd,
|
|
38
|
+
estimateProbeSpendUsd,
|
|
39
|
+
formatClock,
|
|
40
|
+
formatDuration,
|
|
41
|
+
formatUsd,
|
|
42
|
+
hasPricing,
|
|
43
|
+
isCacheMiss,
|
|
44
|
+
parseDurationMs,
|
|
45
|
+
parseUsage,
|
|
46
|
+
parseUsageFromSse,
|
|
47
|
+
parseUsd,
|
|
48
|
+
type CostPerM,
|
|
49
|
+
type ParsedUsage,
|
|
50
|
+
} from "./lib.ts";
|
|
51
|
+
|
|
52
|
+
const STATE_DIR = join(homedir(), ".pi", "cache-keepalive");
|
|
53
|
+
const STATE_FILE = join(STATE_DIR, "state.json");
|
|
54
|
+
const AUTH_FILE = join(process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
|
|
55
|
+
|
|
56
|
+
interface PersistedConfig {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
intervalMs: number;
|
|
59
|
+
/** Stop automatic probing after this much idle time; 0 disables. */
|
|
60
|
+
maxIdleMs: number;
|
|
61
|
+
/** Minimum full-price prompt size (tokens) before a probe counts as a miss. */
|
|
62
|
+
minPromptTokens: number;
|
|
63
|
+
/** max_tokens clamp for probe requests. */
|
|
64
|
+
maxOutputTokens: number;
|
|
65
|
+
/** Session probe-spend ceiling in USD; null disables the cap. */
|
|
66
|
+
spendCapUsd: number | null;
|
|
67
|
+
/** Consecutive cache misses before probing pauses. */
|
|
68
|
+
maxMissStreak: number;
|
|
69
|
+
/** Consecutive failed probes (network/server errors) before probing pauses. */
|
|
70
|
+
maxErrorStreak: number;
|
|
71
|
+
/** True once the first-run setup wizard has completed (or been skipped). */
|
|
72
|
+
initialized: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_CONFIG: Readonly<PersistedConfig> = Object.freeze({
|
|
76
|
+
enabled: false,
|
|
77
|
+
// 7 min: past the ~5 min cache TTL by design. A 7-min probe never hits the
|
|
78
|
+
// cache, so it acts as a single full-price confirmation that the cache is
|
|
79
|
+
// dead, and the default miss=1 stops probing right after — worst-case total
|
|
80
|
+
// spend is one cold read per session. Use /keepalive interval=4m45s for a
|
|
81
|
+
// hit-mode heartbeat (every probe is a cache read, ~10x cheaper).
|
|
82
|
+
intervalMs: 7 * 60_000,
|
|
83
|
+
maxIdleMs: 30 * 60_000,
|
|
84
|
+
minPromptTokens: 512,
|
|
85
|
+
maxOutputTokens: 16,
|
|
86
|
+
spendCapUsd: 1.0,
|
|
87
|
+
maxMissStreak: 1,
|
|
88
|
+
maxErrorStreak: 3,
|
|
89
|
+
initialized: false,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const MIN_INTERVAL_MS = 30_000;
|
|
93
|
+
const PROBE_TIMEOUT_MS = 30_000;
|
|
94
|
+
|
|
95
|
+
const HELP_TEXT = [
|
|
96
|
+
"pi-kimi-keepalive",
|
|
97
|
+
" /keepalive show status",
|
|
98
|
+
" /keepalive setup interactive first-run wizard (maxidle, miss pause, error breaker, spend cap)",
|
|
99
|
+
" /keepalive on|off enable / disable (persisted)",
|
|
100
|
+
" /keepalive now one manual probe (bypasses pauses)",
|
|
101
|
+
" /keepalive resume clear a sticky pause",
|
|
102
|
+
" /keepalive interval=4m45s probe cadence (>= 30s; <= 5m stays inside the cache TTL)",
|
|
103
|
+
" /keepalive maxidle=30m stop probing after this idle time (0 = never stop)",
|
|
104
|
+
" /keepalive miss=1 pause after N consecutive cache misses",
|
|
105
|
+
" /keepalive errors=3 pause after N consecutive probe failures",
|
|
106
|
+
" /keepalive cap=1.0 session probe-spend ceiling in USD (0 = none)",
|
|
107
|
+
" /keepalive token=512 minimum cached prompt size for miss detection",
|
|
108
|
+
" /keepalive maxoutput=16 probe max_tokens clamp",
|
|
109
|
+
" /keepalive reset zero the session stats",
|
|
110
|
+
].join("\n");
|
|
111
|
+
|
|
112
|
+
interface Capture {
|
|
113
|
+
payload: Record<string, unknown>;
|
|
114
|
+
headers: Record<string, string>;
|
|
115
|
+
provider: string;
|
|
116
|
+
api: string | undefined;
|
|
117
|
+
baseUrl: string;
|
|
118
|
+
cost: Partial<CostPerM> | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Stats {
|
|
122
|
+
probes: number;
|
|
123
|
+
hits: number;
|
|
124
|
+
misses: number;
|
|
125
|
+
errors: number;
|
|
126
|
+
savedUsd: number;
|
|
127
|
+
spendUsd: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function (pi: ExtensionAPI) {
|
|
131
|
+
// ---------- mutable state ----------
|
|
132
|
+
|
|
133
|
+
let config: PersistedConfig = { ...DEFAULT_CONFIG };
|
|
134
|
+
|
|
135
|
+
let ctx: ExtensionContext | null = null;
|
|
136
|
+
let capture: Capture | null = null;
|
|
137
|
+
let capturedHeaders: Record<string, string> = {};
|
|
138
|
+
|
|
139
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
140
|
+
let nextProbeAt: number | null = null;
|
|
141
|
+
let lastSettledAt = Date.now();
|
|
142
|
+
let inflight = false;
|
|
143
|
+
|
|
144
|
+
let pausedReason: string | null = null;
|
|
145
|
+
let missStreak = 0;
|
|
146
|
+
let errorStreak = 0;
|
|
147
|
+
|
|
148
|
+
const stats: Stats = { probes: 0, hits: 0, misses: 0, errors: 0, savedUsd: 0, spendUsd: 0 };
|
|
149
|
+
|
|
150
|
+
// ---------- persistence ----------
|
|
151
|
+
|
|
152
|
+
function readConfigFromDisk(): boolean {
|
|
153
|
+
// Returns whether a config file already existed on disk.
|
|
154
|
+
const existed = existsSync(STATE_FILE);
|
|
155
|
+
try {
|
|
156
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, "utf8")) as Partial<PersistedConfig>;
|
|
157
|
+
const int = (value: unknown, fallback: number, min = 1, cap = Number.MAX_SAFE_INTEGER): number =>
|
|
158
|
+
typeof value === "number" && Number.isFinite(value) && value >= min
|
|
159
|
+
? Math.min(Math.floor(value), cap)
|
|
160
|
+
: fallback;
|
|
161
|
+
config = {
|
|
162
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_CONFIG.enabled,
|
|
163
|
+
intervalMs: int(raw.intervalMs, DEFAULT_CONFIG.intervalMs, 1_000),
|
|
164
|
+
maxIdleMs:
|
|
165
|
+
raw.maxIdleMs === 0
|
|
166
|
+
? 0
|
|
167
|
+
: int(raw.maxIdleMs, DEFAULT_CONFIG.maxIdleMs),
|
|
168
|
+
minPromptTokens: int(raw.minPromptTokens, DEFAULT_CONFIG.minPromptTokens, 0),
|
|
169
|
+
maxOutputTokens: int(raw.maxOutputTokens, DEFAULT_CONFIG.maxOutputTokens, 1, 4096),
|
|
170
|
+
spendCapUsd:
|
|
171
|
+
raw.spendCapUsd === 0 || raw.spendCapUsd === null
|
|
172
|
+
? null // 0/null both mean "no cap"
|
|
173
|
+
: typeof raw.spendCapUsd === "number" &&
|
|
174
|
+
Number.isFinite(raw.spendCapUsd) &&
|
|
175
|
+
raw.spendCapUsd > 0
|
|
176
|
+
? raw.spendCapUsd
|
|
177
|
+
: DEFAULT_CONFIG.spendCapUsd,
|
|
178
|
+
maxMissStreak: int(raw.maxMissStreak, DEFAULT_CONFIG.maxMissStreak, 1),
|
|
179
|
+
maxErrorStreak: int(raw.maxErrorStreak, DEFAULT_CONFIG.maxErrorStreak, 1),
|
|
180
|
+
initialized: raw.initialized === true,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
config = { ...DEFAULT_CONFIG };
|
|
184
|
+
}
|
|
185
|
+
return existed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function persistConfig(): void {
|
|
189
|
+
try {
|
|
190
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
191
|
+
writeFileSync(STATE_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
debug("failed to persist config:", error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------- setup wizard ----------
|
|
198
|
+
|
|
199
|
+
async function runSetupWizard(wizardCtx: ExtensionContext, opts: { firstRun: boolean }): Promise<void> {
|
|
200
|
+
if (wizardCtx.hasUI !== true || typeof wizardCtx.ui?.input !== "function") {
|
|
201
|
+
// Headless / remote session: nothing to interact with, keep defaults.
|
|
202
|
+
notify(
|
|
203
|
+
"pi-kimi-keepalive setup needs an interactive UI. Defaults are in effect; " +
|
|
204
|
+
"configure later via /keepalive maxidle=30m miss=1 errors=3 cap=1.0.",
|
|
205
|
+
"info",
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const confirmNext = await wizardCtx.ui.confirm(
|
|
211
|
+
"pi-kimi-keepalive — first-time setup",
|
|
212
|
+
"Set the keepalive guardrails. Press Esc at any prompt to keep the default. " +
|
|
213
|
+
"All values are saved to ~/.pi/cache-keepalive/state.json and can be changed later " +
|
|
214
|
+
"via /keepalive <setting>.",
|
|
215
|
+
);
|
|
216
|
+
if (confirmNext !== true) {
|
|
217
|
+
config.initialized = true;
|
|
218
|
+
persistConfig();
|
|
219
|
+
notify(
|
|
220
|
+
"Setup skipped — defaults kept (interval 7m, maxidle 30m, miss 1, errors 3, cap $1.00). " +
|
|
221
|
+
"Run /keepalive setup to configure later, /keepalive on to enable.",
|
|
222
|
+
"info",
|
|
223
|
+
);
|
|
224
|
+
updateUi();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 1/4 — max idle cutoff
|
|
229
|
+
const maxIdleRaw = await wizardCtx.ui.input(
|
|
230
|
+
"Step 1/4 — Max idle cutoff (now " + formatDuration(config.maxIdleMs) + ")\n" +
|
|
231
|
+
"Stop probing once you have been idle longer than this, so a session left overnight " +
|
|
232
|
+
"does not keep spending quota. Examples: 30m, 1h, 2h — or 0 to never stop.\n" +
|
|
233
|
+
"Leave empty / press Esc to keep the default (30m).",
|
|
234
|
+
"30m",
|
|
235
|
+
);
|
|
236
|
+
if (maxIdleRaw !== undefined && maxIdleRaw.trim() !== "") {
|
|
237
|
+
const ms = parseDurationMs(maxIdleRaw);
|
|
238
|
+
if (ms === null) {
|
|
239
|
+
if (maxIdleRaw.trim() === "0") {
|
|
240
|
+
config.maxIdleMs = 0;
|
|
241
|
+
} else {
|
|
242
|
+
notify(`Invalid duration "${maxIdleRaw}" — keeping ${config.maxIdleMs === 0 ? "disabled" : formatDuration(config.maxIdleMs)}`, "error");
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
config.maxIdleMs = ms;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 2/4 — miss pause threshold
|
|
250
|
+
const missRaw = await wizardCtx.ui.input(
|
|
251
|
+
"Step 2/4 — Miss pause threshold (now " + config.maxMissStreak + ")\n" +
|
|
252
|
+
"Pause probing after this many consecutive probes that did NOT hit the prompt cache " +
|
|
253
|
+
"(a cache hit resets the count). A high cache-read price with no hits means the " +
|
|
254
|
+
"provider's caching behaviour changed; pausing keeps you from burning quota blindly.",
|
|
255
|
+
String(config.maxMissStreak),
|
|
256
|
+
);
|
|
257
|
+
if (missRaw !== undefined && missRaw.trim() !== "") {
|
|
258
|
+
const n = Number(missRaw);
|
|
259
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
260
|
+
notify(`Invalid miss threshold "${missRaw}" — keeping ${config.maxMissStreak}`, "error");
|
|
261
|
+
} else {
|
|
262
|
+
config.maxMissStreak = n;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3/4 — error circuit breaker
|
|
267
|
+
const errorRaw = await wizardCtx.ui.input(
|
|
268
|
+
"Step 3/4 — Error circuit breaker (default " + config.maxErrorStreak + ")\n" +
|
|
269
|
+
"Pause probing after this many consecutive failed probes (network errors, HTTP 5xx). " +
|
|
270
|
+
"Auth failures (HTTP 401/403) always pause immediately regardless of this value.",
|
|
271
|
+
String(config.maxErrorStreak),
|
|
272
|
+
);
|
|
273
|
+
if (errorRaw !== undefined && errorRaw.trim() !== "") {
|
|
274
|
+
const n = Number(errorRaw);
|
|
275
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
276
|
+
notify(`Invalid error threshold — keeping ${config.maxErrorStreak}`, "error");
|
|
277
|
+
} else {
|
|
278
|
+
config.maxErrorStreak = n;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 4/4 — spend cap
|
|
283
|
+
const spendRaw = await wizardCtx.ui.input(
|
|
284
|
+
"Step 4/4 — Session spend cap in USD (default " +
|
|
285
|
+
formatUsd(DEFAULT_CONFIG.spendCapUsd ?? 1.0) +
|
|
286
|
+
")\n" +
|
|
287
|
+
"Ceiling on the estimated USD cost of probes in this session. A probe costs roughly " +
|
|
288
|
+
"the cache-read price of your whole context (about $0.03 per probe at 100k tokens). " +
|
|
289
|
+
"If you're on a Kimi subscription this is still a useful rough gauge. 0 disables the cap.",
|
|
290
|
+
"1.0",
|
|
291
|
+
);
|
|
292
|
+
if (spendRaw !== undefined && spendRaw.trim() !== "") {
|
|
293
|
+
const usd = parseUsd(spendRaw);
|
|
294
|
+
if (usd === null) {
|
|
295
|
+
notify("Invalid USD value — keeping the default cap", "error");
|
|
296
|
+
} else {
|
|
297
|
+
config.spendCapUsd = usd === 0 ? null : usd;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
config.initialized = true;
|
|
302
|
+
persistConfig();
|
|
303
|
+
|
|
304
|
+
const enable = await wizardCtx.ui.confirm(
|
|
305
|
+
"Enable keepalive now?",
|
|
306
|
+
"Probing starts after your next real turn (it needs one captured request first). " +
|
|
307
|
+
"You can toggle anytime with /keepalive on / off.",
|
|
308
|
+
);
|
|
309
|
+
if (enable === true) {
|
|
310
|
+
config.enabled = true;
|
|
311
|
+
pausedReason = null;
|
|
312
|
+
}
|
|
313
|
+
persistConfig();
|
|
314
|
+
persistConfig();
|
|
315
|
+
notify(
|
|
316
|
+
"pi-kimi-keepalive configured:\n" +
|
|
317
|
+
` maxidle: ${config.maxIdleMs === 0 ? "never stop" : formatDuration(config.maxIdleMs)}\n` +
|
|
318
|
+
` miss pause: after ${config.maxMissStreak} consecutive cache misses\n` +
|
|
319
|
+
` error breaker: ${config.maxErrorStreak} consecutive failures\n` +
|
|
320
|
+
` spend cap: ${config.spendCapUsd === null ? "none" : formatUsd(config.spendCapUsd)}\n` +
|
|
321
|
+
" /keepalive status anytime — /keepalive on|off to toggle.",
|
|
322
|
+
"info",
|
|
323
|
+
);
|
|
324
|
+
updateUi();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
function isTargetModel(context: ExtensionContext | null): boolean {
|
|
329
|
+
const model = context?.model;
|
|
330
|
+
if (!model || model.provider !== "kimi-coding") return false;
|
|
331
|
+
if (
|
|
332
|
+
typeof model.baseUrl !== "string" ||
|
|
333
|
+
model.baseUrl.length === 0
|
|
334
|
+
) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
// kimi-coding currently routes through an OpenAI-completions-compatible
|
|
338
|
+
// API ("kimi-openai-completions"); accept the anthropic-messages dialect
|
|
339
|
+
// too in case the provider config changes.
|
|
340
|
+
const api = (model as { api?: unknown }).api;
|
|
341
|
+
return (
|
|
342
|
+
api === "kimi-openai-completions" ||
|
|
343
|
+
api === "anthropic-messages" ||
|
|
344
|
+
(typeof api === "string" && api.endsWith("openai-completions"))
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function splitSetting(token: string): [string, string | undefined] {
|
|
349
|
+
const eq = token.indexOf("=");
|
|
350
|
+
if (eq === -1) return [token, undefined];
|
|
351
|
+
return [token.slice(0, eq), token.slice(eq + 1)];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------- scheduling ----------
|
|
355
|
+
|
|
356
|
+
function clearTimer(): void {
|
|
357
|
+
if (timer !== null) {
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
timer = null;
|
|
360
|
+
}
|
|
361
|
+
nextProbeAt = null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function armed(): boolean {
|
|
365
|
+
return Boolean(config.enabled && capture && !pausedReason && !inflight);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function schedule(delayMs?: number): void {
|
|
369
|
+
clearTimer();
|
|
370
|
+
if (!armed()) return;
|
|
371
|
+
const delay = Math.max(1_000, delayMs ?? config.intervalMs);
|
|
372
|
+
nextProbeAt = Date.now() + delay;
|
|
373
|
+
timer = setTimeout(() => {
|
|
374
|
+
timer = null;
|
|
375
|
+
void onTick();
|
|
376
|
+
}, delay);
|
|
377
|
+
// A pending keepalive timer must never keep the process alive.
|
|
378
|
+
if (timer && typeof (timer as { unref?: unknown }).unref === "function") {
|
|
379
|
+
(timer as unknown as { unref: () => void }).unref();
|
|
380
|
+
}
|
|
381
|
+
updateUi();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function onTick(): Promise<void> {
|
|
385
|
+
if (!ctx || !capture) return;
|
|
386
|
+
if (!ctx.isIdle()) {
|
|
387
|
+
debug("tick skipped: agent busy; agent_settled will re-arm");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const idleFor = Date.now() - lastSettledAt;
|
|
391
|
+
if (config.maxIdleMs > 0 && idleFor >= config.maxIdleMs) {
|
|
392
|
+
pause(`idle for more than maxidle (${formatDuration(idleFor)})`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
await runProbe();
|
|
396
|
+
if (pausedReason) updateUi();
|
|
397
|
+
else schedule();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function pause(reason: string): void {
|
|
401
|
+
pausedReason = reason;
|
|
402
|
+
clearTimer();
|
|
403
|
+
notify(`pi-kimi-keepalive paused — ${reason} (/keepalive resume to retry)`, "warning");
|
|
404
|
+
updateUi();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------- probing ----------
|
|
408
|
+
|
|
409
|
+
function probeEndpoint(): string | null {
|
|
410
|
+
if (!capture) return null;
|
|
411
|
+
const base = capture.baseUrl.replace(/\/+$/, "");
|
|
412
|
+
if (!/^https:\/\//.test(base)) return null;
|
|
413
|
+
return capture.api === "anthropic-messages"
|
|
414
|
+
? `${base}/v1/messages`
|
|
415
|
+
: `${base}/chat/completions`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolve the probe's Authorization header. pi injects OAuth credentials
|
|
420
|
+
* (kimi-coding `Authorization: Bearer <access>`) after the
|
|
421
|
+
* before_provider_headers hook fires, so captured headers usually lack
|
|
422
|
+
* auth; read the current token from pi's auth store instead. Falls back
|
|
423
|
+
* to the forwarded captured headers if the token file is unavailable.
|
|
424
|
+
*/
|
|
425
|
+
function probeHeaders(): Record<string, string> | null {
|
|
426
|
+
if (!capture) return null;
|
|
427
|
+
const headers = buildProbeHeaders(capture.headers);
|
|
428
|
+
if (!headers.authorization) {
|
|
429
|
+
try {
|
|
430
|
+
const raw = JSON.parse(readFileSync(AUTH_FILE, "utf8")) as Record<string, unknown>;
|
|
431
|
+
const entry = (raw["kimi-coding"] ?? null) as { access?: unknown } | null;
|
|
432
|
+
if (entry && typeof entry.access === "string" && entry.access.length > 0) {
|
|
433
|
+
headers.authorization = `Bearer ${entry.access}`;
|
|
434
|
+
} else {
|
|
435
|
+
debug(`no kimi-coding access token in ${AUTH_FILE}`);
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
debug(`auth.json unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (!headers.authorization) return null;
|
|
442
|
+
return headers;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function runProbe(): Promise<boolean> {
|
|
446
|
+
if (inflight || !capture) return false;
|
|
447
|
+
inflight = true;
|
|
448
|
+
try {
|
|
449
|
+
const endpoint = probeEndpoint();
|
|
450
|
+
if (!endpoint) return false;
|
|
451
|
+
for (let attempt = 1 as 1 | 2; attempt <= 2; attempt++) {
|
|
452
|
+
const built = buildProbeBody(capture.payload, config.maxOutputTokens, capture.api, attempt);
|
|
453
|
+
if (!built.ok) {
|
|
454
|
+
recordFailure(`captured payload not replayable: ${built.reason}`);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
let response: Response;
|
|
458
|
+
const probeRequestHeaders = probeHeaders();
|
|
459
|
+
if (!probeRequestHeaders) {
|
|
460
|
+
recordFailure("no Kimi credentials available for the probe");
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
response = await fetch(endpoint, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: probeRequestHeaders,
|
|
467
|
+
body: JSON.stringify(built.body),
|
|
468
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
469
|
+
});
|
|
470
|
+
} catch (error) {
|
|
471
|
+
recordFailure(`network error: ${error instanceof Error ? error.message : String(error)}`);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
const text = await response.text();
|
|
475
|
+
if (response.ok) {
|
|
476
|
+
recordProbeResult(text);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
debug(`probe attempt ${attempt} -> HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
480
|
+
// One retry with a further-reduced terminal-parameter set when the
|
|
481
|
+
// endpoint rejects the probe's extras; the conversation prefix is
|
|
482
|
+
// never modified.
|
|
483
|
+
const retryable = response.status === 400 && attempt === 1;
|
|
484
|
+
if (!retryable) {
|
|
485
|
+
recordFailure(`HTTP ${response.status}`);
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return false;
|
|
490
|
+
} finally {
|
|
491
|
+
inflight = false;
|
|
492
|
+
updateUi();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function recordProbeResult(text: string): void {
|
|
497
|
+
let usage: ParsedUsage;
|
|
498
|
+
try {
|
|
499
|
+
usage = parseUsage(JSON.parse(text) as unknown);
|
|
500
|
+
} catch {
|
|
501
|
+
usage = parseUsageFromSse(text);
|
|
502
|
+
}
|
|
503
|
+
applyProbeUsage(usage);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function applyProbeUsage(usage: ParsedUsage): void {
|
|
507
|
+
if (!capture) return;
|
|
508
|
+
stats.probes += 1;
|
|
509
|
+
stats.spendUsd += estimateProbeSpendUsd(usage, capture.cost);
|
|
510
|
+
|
|
511
|
+
if (!isCacheMiss(usage.inputTokens, usage.cacheReadTokens, config.minPromptTokens)) {
|
|
512
|
+
stats.hits += 1;
|
|
513
|
+
stats.savedUsd += estimateSavedUsd(usage.cacheReadTokens, capture.cost);
|
|
514
|
+
missStreak = 0;
|
|
515
|
+
errorStreak = 0;
|
|
516
|
+
debug(
|
|
517
|
+
`probe hit: cache_read=${usage.cacheReadTokens} input=${usage.inputTokens} saved=${stats.savedUsd.toFixed(4)}`,
|
|
518
|
+
);
|
|
519
|
+
} else {
|
|
520
|
+
stats.misses += 1;
|
|
521
|
+
missStreak += 1;
|
|
522
|
+
debug(`probe miss #${missStreak}: cache_read=0 input=${usage.inputTokens}`);
|
|
523
|
+
if (missStreak >= config.maxMissStreak) {
|
|
524
|
+
pause("probes stopped hitting the prefix cache; waiting for your next real turn");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (
|
|
529
|
+
!pausedReason &&
|
|
530
|
+
config.spendCapUsd !== null &&
|
|
531
|
+
stats.spendUsd >= config.spendCapUsd
|
|
532
|
+
) {
|
|
533
|
+
pause(
|
|
534
|
+
`probe spend ${formatUsd(stats.spendUsd)} reached the cap ${formatUsd(config.spendCapUsd)}`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function recordFailure(message: string): void {
|
|
540
|
+
stats.errors += 1;
|
|
541
|
+
errorStreak += 1;
|
|
542
|
+
debug("probe failed:", message);
|
|
543
|
+
if (/^HTTP 40[13]\b/.test(message)) {
|
|
544
|
+
pause("captured credentials rejected; will recapture after your next real turn");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (errorStreak >= config.maxErrorStreak) {
|
|
548
|
+
pause(`${errorStreak} consecutive probe failures (last: ${message})`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------- UI ----------
|
|
553
|
+
|
|
554
|
+
function notify(text: string, level?: "info" | "warning" | "error"): void {
|
|
555
|
+
debug(text);
|
|
556
|
+
if (ctx?.hasUI !== true) return;
|
|
557
|
+
try {
|
|
558
|
+
ctx.ui.notify(text, level ?? "info");
|
|
559
|
+
} catch {
|
|
560
|
+
// UI unavailable; ignore.
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function updateUi(): void {
|
|
565
|
+
if (ctx?.hasUI !== true) return;
|
|
566
|
+
try {
|
|
567
|
+
const state = pausedReason
|
|
568
|
+
? `paused — ${pausedReason}`
|
|
569
|
+
: !config.enabled
|
|
570
|
+
? "off (/keepalive on)"
|
|
571
|
+
: capture
|
|
572
|
+
? `armed · every ${formatDuration(config.intervalMs)} · next ${formatClock(nextProbeAt)}`
|
|
573
|
+
: "on · waiting for the first real turn";
|
|
574
|
+
const savings = hasPricing(capture?.cost) ? formatUsd(stats.savedUsd) : "n/a (no price data)";
|
|
575
|
+
ctx.ui.setStatus("cache-keepalive", `♥ ${state}`);
|
|
576
|
+
ctx.ui.setWidget("cache-keepalive", [
|
|
577
|
+
"pi-kimi-keepalive",
|
|
578
|
+
` state: ${state}`,
|
|
579
|
+
` probes: ${stats.probes} sent · ${stats.hits} hits · ${stats.misses} misses · ${stats.errors} errors`,
|
|
580
|
+
` est.: saved ${savings} · probe spend ${formatUsd(stats.spendUsd)} · cap ${config.spendCapUsd === null ? "none" : formatUsd(config.spendCapUsd)}`,
|
|
581
|
+
]);
|
|
582
|
+
} catch {
|
|
583
|
+
// UI unavailable; ignore.
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function debug(...parts: unknown[]): void {
|
|
588
|
+
if (process.env.PI_KEEPALIVE_DEBUG) {
|
|
589
|
+
process.stderr.write(`[pi-kimi-keepalive] ${parts.map(String).join(" ")}\n`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---------- command ----------
|
|
594
|
+
|
|
595
|
+
function statusLines(): string[] {
|
|
596
|
+
const route = capture
|
|
597
|
+
? `${capture.provider} (${capture.api ?? "unknown api"}) @ ${capture.baseUrl}`
|
|
598
|
+
: "none yet — probes start after your first real turn";
|
|
599
|
+
return [
|
|
600
|
+
"pi-kimi-keepalive",
|
|
601
|
+
` state: ${config.enabled ? "on" : "off"}${pausedReason ? ` (paused: ${pausedReason})` : ""}`,
|
|
602
|
+
` capture: ${route}`,
|
|
603
|
+
` interval: ${formatDuration(config.intervalMs)} · maxidle ${config.maxIdleMs === 0 ? "off" : formatDuration(config.maxIdleMs)} · minPromptTokens ${config.minPromptTokens} · maxOutput ${config.maxOutputTokens}`,
|
|
604
|
+
` spend cap: ${config.spendCapUsd === null ? "none" : formatUsd(config.spendCapUsd)} · est. probe spend ${formatUsd(stats.spendUsd)}`,
|
|
605
|
+
` probes: ${stats.probes} (hits ${stats.hits}, misses ${stats.misses}, errors ${stats.errors})`,
|
|
606
|
+
` saved: ${hasPricing(capture?.cost) ? formatUsd(stats.savedUsd) : "n/a (no price data)"} · next probe ${formatClock(nextProbeAt)}`,
|
|
607
|
+
];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pi.registerCommand("keepalive", {
|
|
611
|
+
description:
|
|
612
|
+
"Kimi prompt-cache keepalive: setup|on|off|now|resume|status|reset|interval=4m|maxidle=30m|miss=2|errors=3|cap=1|token=512|maxoutput=16",
|
|
613
|
+
handler: async (args, commandCtx) => {
|
|
614
|
+
ctx = commandCtx;
|
|
615
|
+
const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
616
|
+
if (tokens.length === 0 || tokens[0] === "status") {
|
|
617
|
+
notify(statusLines().join("\n"), "info");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
for (const token of tokens) {
|
|
621
|
+
const [key, value] = splitSetting(token);
|
|
622
|
+
switch (key) {
|
|
623
|
+
case "setup":
|
|
624
|
+
await runSetupWizard(commandCtx, { firstRun: false });
|
|
625
|
+
break;
|
|
626
|
+
case "on":
|
|
627
|
+
config.enabled = true;
|
|
628
|
+
pausedReason = null;
|
|
629
|
+
persistConfig();
|
|
630
|
+
notify(
|
|
631
|
+
capture
|
|
632
|
+
? `keepalive on — probing every ${formatDuration(config.intervalMs)}`
|
|
633
|
+
: "keepalive on — probing starts after your next real turn",
|
|
634
|
+
"info",
|
|
635
|
+
);
|
|
636
|
+
break;
|
|
637
|
+
case "off":
|
|
638
|
+
config.enabled = false;
|
|
639
|
+
persistConfig();
|
|
640
|
+
clearTimer();
|
|
641
|
+
notify("keepalive off", "info");
|
|
642
|
+
break;
|
|
643
|
+
case "now":
|
|
644
|
+
if (!capture) {
|
|
645
|
+
notify("nothing captured yet — run one real turn first", "warning");
|
|
646
|
+
} else if (commandCtx.isIdle()) {
|
|
647
|
+
pausedReason = null;
|
|
648
|
+
const ok = await runProbe();
|
|
649
|
+
notify(ok ? statusLines().join("\n") : "probe failed — see /keepalive status", "info");
|
|
650
|
+
if (ok && !pausedReason && config.enabled) schedule();
|
|
651
|
+
} else {
|
|
652
|
+
notify("agent is busy; probe skipped", "info");
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
case "resume":
|
|
656
|
+
pausedReason = null;
|
|
657
|
+
missStreak = 0;
|
|
658
|
+
errorStreak = 0;
|
|
659
|
+
notify("keepalive resumed", "info");
|
|
660
|
+
break;
|
|
661
|
+
case "interval": {
|
|
662
|
+
const ms = value !== undefined ? parseDurationMs(value) : null;
|
|
663
|
+
if (ms === null || ms < MIN_INTERVAL_MS) {
|
|
664
|
+
notify(`/keepalive interval=${value ?? "?"} rejected — minimum 30s, e.g. interval=4m`, "error");
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
config.intervalMs = ms;
|
|
668
|
+
persistConfig();
|
|
669
|
+
notify(`interval set to ${formatDuration(ms)}`, "info");
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case "maxidle": {
|
|
673
|
+
if (value === "0") {
|
|
674
|
+
config.maxIdleMs = 0;
|
|
675
|
+
persistConfig();
|
|
676
|
+
notify("maxidle disabled — probing continues while idle", "info");
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
const ms = value !== undefined ? parseDurationMs(value) : null;
|
|
680
|
+
if (ms === null) {
|
|
681
|
+
notify("usage: /keepalive maxidle=30m (0 disables the cutoff)", "error");
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
persistConfig();
|
|
685
|
+
notify(ms === 0 ? "maxidle disabled — probing continues while idle" : `maxidle set to ${formatDuration(ms)}`, "info");
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case "miss": {
|
|
689
|
+
const n = value !== undefined ? Number(value) : NaN;
|
|
690
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
691
|
+
notify("usage: /keepalive miss=2 (pause after N consecutive cache misses)", "error");
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
config.maxMissStreak = n;
|
|
695
|
+
persistConfig();
|
|
696
|
+
notify(`miss pause threshold set to ${n} consecutive cache misses`, "info");
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case "errors": {
|
|
700
|
+
const n = value !== undefined ? Number(value) : NaN;
|
|
701
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
702
|
+
notify("usage: /keepalive errors=3 (pause after N consecutive probe failures)", "error");
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
config.maxErrorStreak = n;
|
|
706
|
+
persistConfig();
|
|
707
|
+
notify(`error circuit breaker set to ${n} consecutive failures`, "info");
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case "cap": {
|
|
711
|
+
const usd = value !== undefined ? parseUsd(value) : null;
|
|
712
|
+
if (usd === null) {
|
|
713
|
+
notify("usage: /keepalive cap=1.0 (0 removes the cap)", "error");
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
config.spendCapUsd = usd === 0 ? null : usd;
|
|
717
|
+
persistConfig();
|
|
718
|
+
notify(
|
|
719
|
+
config.spendCapUsd === null
|
|
720
|
+
? "spend cap removed"
|
|
721
|
+
: `spend cap set to ${formatUsd(config.spendCapUsd)}`,
|
|
722
|
+
"info",
|
|
723
|
+
);
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case "token": {
|
|
727
|
+
const n = value !== undefined ? Number(value) : NaN;
|
|
728
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
729
|
+
notify("usage: /keepalive token=512", "error");
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
config.minPromptTokens = Math.floor(n);
|
|
733
|
+
persistConfig();
|
|
734
|
+
notify(`minPromptTokens set to ${config.minPromptTokens}`, "info");
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case "maxoutput": {
|
|
738
|
+
const n = value !== undefined ? Number(value) : NaN;
|
|
739
|
+
if (!Number.isFinite(n) || n < 1 || n > 4096) {
|
|
740
|
+
notify("usage: /keepalive maxoutput=16 (1..4096)", "error");
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
config.maxOutputTokens = Math.floor(n);
|
|
744
|
+
persistConfig();
|
|
745
|
+
notify(`maxOutputTokens set to ${config.maxOutputTokens}`, "info");
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
case "reset":
|
|
749
|
+
stats.probes = 0;
|
|
750
|
+
stats.hits = 0;
|
|
751
|
+
stats.misses = 0;
|
|
752
|
+
stats.errors = 0;
|
|
753
|
+
stats.savedUsd = 0;
|
|
754
|
+
stats.spendUsd = 0;
|
|
755
|
+
notify("stats reset", "info");
|
|
756
|
+
break;
|
|
757
|
+
default:
|
|
758
|
+
notify(HELP_TEXT, "info");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
updateUi();
|
|
762
|
+
if (config.enabled && !pausedReason && capture && !timer && !inflight) schedule();
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// ---------- hooks ----------
|
|
767
|
+
|
|
768
|
+
pi.on("before_provider_headers", (event) => {
|
|
769
|
+
if (!ctx || !isTargetModel(ctx)) return;
|
|
770
|
+
const record: Record<string, string> = {};
|
|
771
|
+
for (const [key, value] of Object.entries(event.headers ?? {})) {
|
|
772
|
+
if (typeof value === "string") record[key] = value;
|
|
773
|
+
}
|
|
774
|
+
capturedHeaders = record;
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
pi.on("before_provider_request", (event) => {
|
|
778
|
+
if (!ctx || !isTargetModel(ctx)) return;
|
|
779
|
+
const model = ctx.model;
|
|
780
|
+
const payload: unknown = event.payload;
|
|
781
|
+
if (!model || !payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
|
782
|
+
const raw = payload as Record<string, unknown>;
|
|
783
|
+
if (!Array.isArray(raw.messages) || raw.messages.length === 0) return;
|
|
784
|
+
if (raw.system !== undefined && !Array.isArray(raw.system) && typeof raw.system !== "string") return;
|
|
785
|
+
capture = {
|
|
786
|
+
payload: structuredClone(raw),
|
|
787
|
+
headers: capturedHeaders,
|
|
788
|
+
provider: model.provider,
|
|
789
|
+
api: (model as { api?: string }).api,
|
|
790
|
+
baseUrl: model.baseUrl ?? "",
|
|
791
|
+
cost: (model.cost ?? undefined) as Partial<CostPerM> | undefined,
|
|
792
|
+
};
|
|
793
|
+
// A fresh real request means fresh credentials and a warm prefix cache;
|
|
794
|
+
// automatically recover from any sticky pause.
|
|
795
|
+
if (pausedReason !== null || missStreak > 0 || errorStreak > 0) {
|
|
796
|
+
pausedReason = null;
|
|
797
|
+
missStreak = 0;
|
|
798
|
+
errorStreak = 0;
|
|
799
|
+
debug("fresh real request observed — keepalive unpaused");
|
|
800
|
+
}
|
|
801
|
+
updateUi();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
pi.on("session_start", async (event, sessionCtx) => {
|
|
805
|
+
ctx = sessionCtx;
|
|
806
|
+
const configExisted = readConfigFromDisk();
|
|
807
|
+
// Captures are bound to the previous session's credentials; require a new capture.
|
|
808
|
+
capture = null;
|
|
809
|
+
capturedHeaders = {};
|
|
810
|
+
lastSettledAt = Date.now();
|
|
811
|
+
clearTimer();
|
|
812
|
+
if (
|
|
813
|
+
event.reason === "startup" &&
|
|
814
|
+
!configExisted &&
|
|
815
|
+
sessionCtx.hasUI === true &&
|
|
816
|
+
typeof sessionCtx.ui?.input === "function"
|
|
817
|
+
) {
|
|
818
|
+
// Fresh install: walk the user through the guardrails once.
|
|
819
|
+
try {
|
|
820
|
+
await runSetupWizard(sessionCtx, { firstRun: true });
|
|
821
|
+
} catch (error) {
|
|
822
|
+
debug("setup wizard failed:", error instanceof Error ? error.message : error);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
updateUi();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
pi.on("agent_settled", async (_event, sessionCtx) => {
|
|
829
|
+
ctx = sessionCtx;
|
|
830
|
+
lastSettledAt = Date.now();
|
|
831
|
+
if (armed()) schedule();
|
|
832
|
+
else updateUi();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
pi.on("agent_start", async () => {
|
|
836
|
+
// Real activity refreshes the cache by itself; do not probe meanwhile.
|
|
837
|
+
clearTimer();
|
|
838
|
+
updateUi();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
pi.on("session_shutdown", async () => {
|
|
842
|
+
clearTimer();
|
|
843
|
+
});
|
|
844
|
+
}
|