@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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-warmer.d.ts","sourceRoot":"","sources":["../../lib/replay-warmer.ts"],"names":[],"mappings":"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"}
@@ -1,118 +1,19 @@
1
- import type { KeepaliveConfig } from "./config";
2
- export type PingRecord = {
3
- at: number;
4
- hit: boolean;
5
- input: number;
6
- cacheRead: number;
7
- cacheWrite: number;
8
- output: number;
1
+ import type { Logger } from "./logger";
2
+ import type { ProcessTotals, SessionWarmEntry, WarmStateSink } from "./types";
3
+ export type StateStoreOptions = {
4
+ logger?: Logger;
5
+ now?: () => number;
9
6
  };
10
7
  /**
11
- * Request settings of the last real user turn. A ping must replay them exactly:
12
- * a prompt without explicit settings runs as opencode's default agent with no
13
- * variant, which changes the request shape (missing the cache) and overwrites
14
- * the session's persisted agent/model/variant.
8
+ * Create an isolated v2 snapshot writer. Mutations are debounced, dispose flushes
9
+ * the latest snapshot before removing it, and all filesystem failures stay
10
+ * best-effort so state reporting cannot disrupt replay execution.
15
11
  */
16
- export type PingRequest = {
17
- agent?: string;
18
- providerID: string;
19
- modelID: string;
20
- variant?: string;
21
- };
22
- /** Bookkeeping for the ping request currently in flight. */
23
- export type InflightPing = {
24
- /** Epoch ms the ping attempt began (before the pre-flight status check). */
25
- startedAt: number;
26
- /** The prompt request has been issued. */
27
- sent: boolean;
28
- /** The ping's own user message, identified by its ping-token text part. */
29
- messageID?: string;
30
- /**
31
- * New user messages seen during the ping whose text has not yet been observed.
32
- * Each is classified by its first text part: the ping token marks our own
33
- * message, anything else a real turn.
34
- */
35
- pending: Map<string, unknown>;
36
- /**
37
- * A real user message appeared while the ping was in flight. opencode joins such
38
- * a prompt to the running ping loop, so the rest of the run is real work.
39
- */
40
- interrupted: boolean;
41
- };
42
- /** Live per-session bookkeeping held by the server plugin. */
43
- export type SessionKeepalive = {
44
- sessionID: string;
45
- /** Whether the session's provider/model pair supports cache warming. */
46
- eligible: boolean;
47
- modelLabel?: string;
48
- /** Epoch ms of the last real assistant response (excludes ping replies). */
49
- lastResponseAt: number;
50
- /** Epoch ms the current idle stretch began. */
51
- idleSince: number;
52
- /** Epoch ms after which warming stops and the cache is allowed to go cold. */
53
- windowEndsAt: number;
54
- /** Epoch ms the next ping is due. */
55
- nextPingAt: number;
56
- /** Number of scheduled ping requests attempted during this warm window. */
57
- pingsSent: number;
58
- /** A real turn is currently running. */
59
- busy: boolean;
60
- /** A keepalive ping is currently in flight. */
61
- warming: boolean;
62
- /** The in-flight ping; set exactly while `warming` is true. */
63
- ping?: InflightPing;
64
- /** ID and creation time of the newest user message seen, to tell new turns from re-emitted old messages. */
65
- lastUserID?: string;
66
- lastUserAt?: number;
67
- /** Settings of the last real user turn, replayed by pings. */
68
- request?: PingRequest;
69
- /** Within the warm window and actively scheduling pings. */
70
- active: boolean;
71
- /** True while `armWindow` is resolving session metadata (async guard). */
72
- arming?: boolean;
73
- /** Epoch ms the last sent ping finished; ignores its late events and marks the last cache touch. */
74
- lastPingAt?: number;
75
- lastPing?: PingRecord;
76
- };
77
- /** Lean projection persisted to disk for the TUI reader. */
78
- export type PersistedSession = {
79
- eligible: boolean;
80
- modelLabel?: string;
81
- lastResponseAt: number;
82
- idleSince: number;
83
- windowEndsAt: number;
84
- nextPingAt: number;
85
- intervalMs: number;
86
- pingsSent: number;
87
- busy: boolean;
88
- warming: boolean;
89
- active: boolean;
90
- lastPing?: PingRecord;
91
- };
92
- export type PersistedState = {
93
- version: 1;
94
- updatedAt: number;
95
- enabled: boolean;
96
- intervalMs: number;
97
- windowMs: number;
98
- sessions: Record<string, PersistedSession>;
99
- };
100
- export declare class KeepaliveStore {
101
- private readonly config;
102
- private readonly directory;
103
- private readonly sessions;
104
- private writeQueue;
105
- private enabled;
106
- constructor(config: KeepaliveConfig, directory: string);
107
- setEnabled(enabled: boolean): void;
108
- get(sessionID: string): SessionKeepalive | undefined;
109
- ensure(sessionID: string): SessionKeepalive;
110
- remove(sessionID: string): void;
111
- all(): SessionKeepalive[];
112
- /**
113
- * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
114
- * returned promise resolves once this snapshot and every earlier one are written.
115
- */
116
- persist(): Promise<void>;
117
- }
12
+ export declare function createStateStore(directory: string, options?: StateStoreOptions): WarmStateSink;
13
+ /** Return the newest self-contained entry for a session across matching v2 files. */
14
+ export declare function readSessionEntry(directory: string, sessionID: string): SessionWarmEntry | undefined;
15
+ /** Sum process-level totals from live snapshots for this directory. */
16
+ export declare function readProcessTotals(directory: string): ProcessTotals;
17
+ /** Remove only v2 snapshots whose owner PID is confirmed dead. */
18
+ export declare function cleanupStateFiles(directory: string, logger?: Logger): void;
118
19
  //# sourceMappingURL=state.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../lib/state.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAEtC,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAc7E,MAAM,MAAM,iBAAiB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,CAAA;AAEvE;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,iBAAsB,GAChC,aAAa,CAoFf;AAED,qFAAqF;AACrF,wBAAgB,gBAAgB,CAC5B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAClB,gBAAgB,GAAG,SAAS,CAO9B;AAED,uEAAuE;AACvE,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAgBlE;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAmB1E"}
@@ -1,3 +1,5 @@
1
+ /** @jsxImportSource @opentui/solid */
1
2
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
+ /** Register runtime enable/disable commands for the current session only. */
2
4
  export declare function registerKeepaliveCommands(api: TuiPluginApi): void;
3
5
  //# sourceMappingURL=commands.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../../lib/tui/commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAG3D,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAmDjE"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../../lib/tui/commands.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAG3D,6EAA6E;AAC7E,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAqEjE"}
@@ -1,14 +1,14 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
+ import type { SessionWarmEntry } from "../types";
2
3
  type Theme = Record<string, any>;
3
- /**
4
- * Persistent readout mounted in the `sidebar_footer` slot. Shows time since the
5
- * last real agent response plus keepalive pings sent / left. Data comes from the
6
- * server plugin's state file; the idle timer ticks locally every second.
7
- */
4
+ /** Display the persisted replay-warming state for the current session only. */
8
5
  export declare function KeepaliveFooter(props: {
9
6
  theme: Theme;
10
7
  directory: string;
11
8
  sessionID?: string;
12
9
  }): any;
10
+ /** Pure formatter for the two-line footer's optional telemetry. */
11
+ export declare function formatDetails(entry: SessionWarmEntry, now?: number): string | undefined;
12
+ export declare function formatStatusLine(entry: SessionWarmEntry, remainingMs: number): string;
13
13
  export {};
14
14
  //# sourceMappingURL=footer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../lib/tui/footer.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAUtC,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAUhC;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,OA+G7F"}
1
+ {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../lib/tui/footer.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAItC,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,UAAU,CAAA;AAI3D,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAEhC,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,OAkD7F;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,KAAK,EAAE,gBAAgB,EAAE,GAAG,SAAa,GAAG,MAAM,GAAG,SAAS,CAE3F;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAYrF"}
@@ -1,3 +1,11 @@
1
1
  export declare function mmss(ms: number): string;
2
2
  export declare function kfmt(n: number): string;
3
+ /**
4
+ * Parse a human duration such as "4m30s", "4.5m", "270s", "1h" or "28m 20s".
5
+ * A bare number means minutes. Returns undefined for anything unparseable;
6
+ * range checks are the caller's concern.
7
+ */
8
+ export declare function parseDuration(input: string): number | undefined;
9
+ /** Compact duration label, e.g. 270000 -> "4m 30s", 3600000 -> "1h". */
10
+ export declare function formatDuration(ms: number): string;
3
11
  //# sourceMappingURL=format.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../lib/tui/format.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAKvC;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAKtC"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../lib/tui/format.ts"],"names":[],"mappings":"AAAA,wBAAgB,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAKvC;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAStC;AAID;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAa/D;AAED,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAWjD"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared contracts between the server-side replay engine and the state/TUI layer.
3
+ *
4
+ * The engine produces one self-contained {@link SessionWarmEntry} per tracked root
5
+ * session and hands it to a {@link WarmStateSink}; the TUI reads persisted entries
6
+ * back per session. Entries never contain request URLs with queries, headers,
7
+ * bodies, credentials, or conversation text.
8
+ */
9
+ /** Wire API family of a recorded request, derived from the request pathname. */
10
+ export type WarmApi = "messages" | "responses" | "chat";
11
+ /**
12
+ * Display/lifecycle state of a session's cache warming.
13
+ * - `active`: a real model request/step is in flight; no replay is sent.
14
+ * - `busy`: the session is blocked on a running tool; replays are scheduled.
15
+ * - `idle`: the session is idle between turns; replays are scheduled.
16
+ * - `stopped`: warming ended for the current gap; see {@link StopReason}.
17
+ * - `off`: warming is disabled by config or runtime control.
18
+ */
19
+ export type WarmState = "active" | "busy" | "idle" | "stopped" | "off";
20
+ /**
21
+ * Why warming stopped for the current gap.
22
+ * - `cap`: the per-gap replay cap was reached.
23
+ * - `lapsed`: the cache is believed expired (failed retry, or a replay reported a cache write instead of a read).
24
+ * - `rejected-4xx`: the provider rejected the replay with 400/401/403.
25
+ * - `disabled`: runtime control or config turned warming off.
26
+ */
27
+ export type StopReason = "cap" | "lapsed" | "rejected-4xx" | "disabled";
28
+ /** Outcome of the most recent replay attempt. */
29
+ export interface ReplayResult {
30
+ /** Replay send time, epoch ms. */
31
+ at: number;
32
+ /** HTTP status; 0 for network error or timeout. */
33
+ status: number;
34
+ /** Milliseconds from issuing the replay fetch to the API-specific abort point. */
35
+ latencyMs: number;
36
+ /** Cached prompt tokens reported by the provider, when parseable before abort (messages API). */
37
+ cacheRead?: number;
38
+ /** Cache-write prompt tokens reported by the provider, when parseable before abort (messages API). */
39
+ cacheWrite?: number;
40
+ }
41
+ /** Result of the first real model call after a gap that had at least one successful replay. */
42
+ export interface ResumeResult {
43
+ /** step-finish time, epoch ms. */
44
+ at: number;
45
+ /** cache.read tokens of that call. */
46
+ read: number;
47
+ /** Prompt tokens (input + cache.read + cache.write) of the last call before the gap. */
48
+ promptPrev: number;
49
+ /** True when read >= 0.9 * promptPrev. */
50
+ hit: boolean;
51
+ /** Successful replays spent in the gap. */
52
+ replays: number;
53
+ /**
54
+ * Cache-write tokens avoided for the gap: `promptPrev` on a hit, 0 on a miss.
55
+ * Gross figure shown to users as "saved"; replay cost is reported separately.
56
+ */
57
+ avoidedTokens: number;
58
+ /**
59
+ * Cached-read tokens spent by the gap's successful replays (sum of each replay's
60
+ * reported cacheRead, falling back to promptPrev when not parsed). Logged only.
61
+ */
62
+ replayReadTokens: number;
63
+ }
64
+ /**
65
+ * Complete, self-contained display state for one root session. Every field the TUI
66
+ * needs is here so readers never depend on file-level or process-level fields.
67
+ */
68
+ export interface SessionWarmEntry {
69
+ sessionID: string;
70
+ /** Last time this entry changed, epoch ms; readers pick the freshest entry per session. */
71
+ updatedAt: number;
72
+ /** Effective enablement (config AND runtime control). */
73
+ enabled: boolean;
74
+ /** Model id from the recorded request body. */
75
+ model: string;
76
+ api: WarmApi;
77
+ state: WarmState;
78
+ stopReason?: StopReason;
79
+ /** HTTP status that caused `rejected-4xx`. */
80
+ stopStatus?: number;
81
+ /** Monotonic per-session gap counter; a gap starts at each real request send. */
82
+ gapId: number;
83
+ /** Successful replays in the current gap. */
84
+ gapReplays: number;
85
+ /** Per-gap replay cap in effect. */
86
+ cap: number;
87
+ /** Next scheduled replay time, epoch ms; null when none is scheduled. */
88
+ nextReplayAt: number | null;
89
+ /** Successful replays across the session's lifetime in this process. */
90
+ sessionReplays: number;
91
+ /** Sum of ResumeResult.avoidedTokens across the session's gaps in this process. */
92
+ avoidedTokens: number;
93
+ /** Resumes (first call after a gap with ≥1 successful replay) that hit, in this process. */
94
+ resumeHits: number;
95
+ /** All resumes (hits + misses) in this process. */
96
+ resumeCount: number;
97
+ lastReplay?: ReplayResult;
98
+ resume?: ResumeResult;
99
+ }
100
+ /** Process-wide aggregates, informational only (never used to gate display). */
101
+ export interface ProcessTotals {
102
+ replays: number;
103
+ avoidedTokens: number;
104
+ replayReadTokens: number;
105
+ resumeHits: number;
106
+ resumeMisses: number;
107
+ }
108
+ /**
109
+ * Destination for engine state. Implementations persist sanitized entries for the
110
+ * TUI. Calls are synchronous and must never throw; persistence may be debounced.
111
+ * Single-threaded use from the plugin's event loop is assumed.
112
+ */
113
+ export interface WarmStateSink {
114
+ /** Replace the entry for `entry.sessionID`. */
115
+ upsert(entry: SessionWarmEntry): void;
116
+ /** Forget a session (e.g. deleted). */
117
+ remove(sessionID: string): void;
118
+ /** Replace process totals. */
119
+ setTotals(totals: ProcessTotals): void;
120
+ /** Flush pending writes and remove this instance's persisted file. Never rejects. */
121
+ dispose(): Promise<void>;
122
+ }
123
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../lib/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,gFAAgF;AAChF,MAAM,MAAM,OAAO,GAAG,UAAU,GAAG,WAAW,GAAG,MAAM,CAAA;AAEvD;;;;;;;GAOG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,KAAK,CAAA;AAEtE;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,cAAc,GAAG,UAAU,CAAA;AAEvE,iDAAiD;AACjD,MAAM,WAAW,YAAY;IACzB,kCAAkC;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAA;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAA;IACjB,iGAAiG;IACjG,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,+FAA+F;AAC/F,MAAM,WAAW,YAAY;IACzB,kCAAkC;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,wFAAwF;IACxF,UAAU,EAAE,MAAM,CAAA;IAClB,0CAA0C;IAC1C,GAAG,EAAE,OAAO,CAAA;IACZ,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,gBAAgB,EAAE,MAAM,CAAA;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,OAAO,EAAE,OAAO,CAAA;IAChB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,OAAO,CAAA;IACZ,KAAK,EAAE,SAAS,CAAA;IAChB,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb,6CAA6C;IAC7C,UAAU,EAAE,MAAM,CAAA;IAClB,oCAAoC;IACpC,GAAG,EAAE,MAAM,CAAA;IACX,yEAAyE;IACzE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,wEAAwE;IACxE,cAAc,EAAE,MAAM,CAAA;IACtB,mFAAmF;IACnF,aAAa,EAAE,MAAM,CAAA;IACrB,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAA;IAClB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,YAAY,CAAA;IACzB,MAAM,CAAC,EAAE,YAAY,CAAA;CACxB;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,EAAE,MAAM,CAAA;IACrB,gBAAgB,EAAE,MAAM,CAAA;IACxB,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC1B,+CAA+C;IAC/C,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACrC,uCAAuC;IACvC,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,8BAA8B;IAC9B,SAAS,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAA;IACtC,qFAAqF;IACrF,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B"}
package/dist/tui.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
+ import type { SessionWarmEntry } from "./lib/types";
3
+ /** Route-level owner for deduplicating rejected replay notifications. */
4
+ export declare function notifyRejectedReplayOnce(notified: Set<string>, sessionID: string, entry: SessionWarmEntry, toast: (message: string) => void): void;
2
5
  declare const _default: {
3
6
  id: string;
4
7
  tui: import("@opencode-ai/plugin/tui").TuiPlugin;
package/dist/tui.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../tui.tsx"],"names":[],"mappings":"AAAA,sCAAsC;;;;;AAwBtC,wBAAgF"}
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../tui.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AAMtC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEnD,yEAAyE;AACzE,wBAAgB,wBAAwB,CACpC,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,EACrB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,gBAAgB,EACvB,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GACjC,IAAI,CAWN;;;;;AAuCD,wBAAgF"}
package/lib/config.ts CHANGED
@@ -1,101 +1,133 @@
1
1
  export type KeepaliveConfig = {
2
- /** Master switch. When false the plugin registers no timers or hooks. */
3
2
  enabled: boolean
4
- /** Milliseconds between pings. Must stay under the shortest supported provider TTL. */
5
- intervalMs: number
6
- /**
7
- * How long to keep warming after the last real response before giving up and
8
- * letting the cache go cold. The default follows the Copilot Claude
9
- * write/read break-even; providers with automatic caching may be cheaper.
10
- */
11
- windowMs: number
12
- /**
13
- * Append a stable keepalive instruction to the system prompt so the model
14
- * answers a `~` ping with a single `~` token and never calls tools. The
15
- * instruction is added to real turns too, so the cached prefix stays identical.
16
- */
17
- injectSystemInstruction: boolean
18
- /** The single character/token used as the keepalive suffix. */
19
- pingToken: string
20
- /** Remove synthetic ping turns after measuring their cache usage. */
21
- revertPing: boolean
22
- /** Warm subagent/child sessions too. Off by default to avoid wasted pings. */
3
+ intervals: Record<string, number>
4
+ hosts: string[]
5
+ cacheReadFactor: number
6
+ missFactor: number
7
+ maxReplaysPerGap: "auto" | number
23
8
  includeChildSessions: boolean
9
+ replayTimeoutMs: number
10
+ maxStoredBytes: number
24
11
  debug: boolean
25
- /** providerID substrings eligible for warming (cache-controlled providers). */
26
- providerAllowlist: string[]
27
- /** modelID substrings eligible for warming. */
28
- modelAllowlist: string[]
29
12
  }
30
13
 
31
14
  const DEFAULTS: KeepaliveConfig = {
32
15
  enabled: true,
33
- intervalMs: 270_000, // 4.5 min — below the shortest supported cache TTL
34
- windowMs: 3_300_000, // 55 min — the Copilot Claude write/read break-even
35
- injectSystemInstruction: true,
36
- pingToken: "~",
37
- revertPing: true,
16
+ intervals: { claude: 285_000, gpt: 1_680_000 },
17
+ hosts: ["githubcopilot.com"],
18
+ cacheReadFactor: 0.1,
19
+ missFactor: 1,
20
+ maxReplaysPerGap: "auto",
38
21
  includeChildSessions: false,
22
+ replayTimeoutMs: 60_000,
23
+ maxStoredBytes: 67_108_864,
39
24
  debug: false,
40
- providerAllowlist: ["copilot"],
41
- modelAllowlist: ["claude", "anthropic", "sonnet", "opus", "haiku", "gpt"],
42
25
  }
43
26
 
44
- export function getConfig(options: Record<string, unknown> | undefined): KeepaliveConfig {
45
- const o = options ?? {}
46
- const env = process.env
27
+ const DEPRECATED = [
28
+ "intervalMs",
29
+ "intervalSeconds",
30
+ "windowMs",
31
+ "windowMinutes",
32
+ "revertPing",
33
+ "pingToken",
34
+ "injectSystemInstruction",
35
+ "providerAllowlist",
36
+ "modelAllowlist",
37
+ "claudeBusyWarm",
38
+ "claudeBusyWarmIntervalMs",
39
+ "claudeBusyWarmWindowMs",
40
+ ]
41
+ const warnedDeprecated = new Set<string>()
47
42
 
43
+ export function getConfig(
44
+ options: Record<string, unknown> | undefined,
45
+ warn: (message: string) => void = () => {},
46
+ ): KeepaliveConfig {
47
+ const option = options ?? {}
48
+ const env = process.env
49
+ for (const key of DEPRECATED) {
50
+ if (key in option && !warnedDeprecated.has(key)) {
51
+ warnedDeprecated.add(key)
52
+ warn(`deprecated option ${key} is ignored`)
53
+ }
54
+ }
55
+ const parseJson = (raw: string | undefined): unknown => {
56
+ if (!raw) return undefined
57
+ try {
58
+ return JSON.parse(raw)
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+ const intervals = objectOption(
64
+ option.intervals ?? parseJson(env.OPENCODE_KEEPALIVE_INTERVALS),
65
+ DEFAULTS.intervals,
66
+ )
67
+ const hosts = listOption(
68
+ option.hosts ?? parseJson(env.OPENCODE_KEEPALIVE_HOSTS),
69
+ DEFAULTS.hosts,
70
+ )
71
+ const maxRaw = option.maxReplaysPerGap ?? env.OPENCODE_KEEPALIVE_MAX_REPLAYS_PER_GAP
72
+ const maxReplaysPerGap =
73
+ maxRaw === "auto"
74
+ ? "auto"
75
+ : positiveNumber(maxRaw) !== undefined
76
+ ? positiveNumber(maxRaw)!
77
+ : DEFAULTS.maxReplaysPerGap
48
78
  return {
49
- enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== "false" && DEFAULTS.enabled),
50
- intervalMs: durationOpt(
51
- o.intervalMs,
52
- o.intervalSeconds,
53
- numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs),
79
+ enabled: boolOption(
80
+ option.enabled,
81
+ env.OPENCODE_KEEPALIVE_ENABLED === undefined
82
+ ? DEFAULTS.enabled
83
+ : env.OPENCODE_KEEPALIVE_ENABLED !== "false",
54
84
  ),
55
- windowMs: durationOpt(
56
- o.windowMs,
57
- o.windowMinutes ? Number(o.windowMinutes) * 60 : undefined,
58
- numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs),
85
+ intervals,
86
+ hosts,
87
+ cacheReadFactor:
88
+ positiveNumber(option.cacheReadFactor ?? env.OPENCODE_KEEPALIVE_CACHE_READ_FACTOR) ??
89
+ DEFAULTS.cacheReadFactor,
90
+ missFactor:
91
+ positiveNumber(option.missFactor ?? env.OPENCODE_KEEPALIVE_MISS_FACTOR) ??
92
+ DEFAULTS.missFactor,
93
+ maxReplaysPerGap,
94
+ includeChildSessions: boolOption(
95
+ option.includeChildSessions,
96
+ env.OPENCODE_KEEPALIVE_INCLUDE_CHILD_SESSIONS === "true" ||
97
+ DEFAULTS.includeChildSessions,
59
98
  ),
60
- injectSystemInstruction: boolOpt(
61
- o.injectSystemInstruction,
62
- DEFAULTS.injectSystemInstruction,
63
- ),
64
- pingToken:
65
- typeof o.pingToken === "string" && o.pingToken.length > 0
66
- ? o.pingToken
67
- : DEFAULTS.pingToken,
68
- revertPing: boolOpt(
69
- o.revertPing,
70
- env.OPENCODE_KEEPALIVE_REVERT_PING !== "false" && DEFAULTS.revertPing,
71
- ),
72
- includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),
73
- debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
74
- providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),
75
- modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist),
99
+ replayTimeoutMs:
100
+ positiveNumber(option.replayTimeoutMs ?? env.OPENCODE_KEEPALIVE_REPLAY_TIMEOUT_MS) ??
101
+ DEFAULTS.replayTimeoutMs,
102
+ maxStoredBytes:
103
+ positiveNumber(option.maxStoredBytes ?? env.OPENCODE_KEEPALIVE_MAX_STORED_BYTES) ??
104
+ DEFAULTS.maxStoredBytes,
105
+ debug: boolOption(option.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
76
106
  }
77
107
  }
78
108
 
79
- function boolOpt(value: unknown, fallback: boolean): boolean {
109
+ function boolOption(value: unknown, fallback: boolean): boolean {
80
110
  return typeof value === "boolean" ? value : fallback
81
111
  }
82
112
 
83
- function listOpt(value: unknown, fallback: string[]): string[] {
84
- if (!Array.isArray(value)) return fallback
85
- const filtered = value.filter((item): item is string => typeof item === "string")
86
- return filtered.length > 0 ? filtered : fallback
113
+ function positiveNumber(value: unknown): number | undefined {
114
+ const parsed = typeof value === "string" ? Number(value) : value
115
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
87
116
  }
88
117
 
89
- /** Accept an explicit `*Ms` option, a `*Seconds` option, or fall back. */
90
- function durationOpt(ms: unknown, seconds: unknown, fallback: number): number {
91
- if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms
92
- if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0)
93
- return seconds * 1000
94
- return fallback
118
+ function objectOption(value: unknown, fallback: Record<string, number>): Record<string, number> {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
120
+ const entries = Object.entries(value).flatMap(([key, interval]) => {
121
+ const parsed = positiveNumber(interval)
122
+ return parsed === undefined ? [] : [[key.toLowerCase(), parsed] as const]
123
+ })
124
+ return entries.length ? Object.fromEntries(entries) : fallback
95
125
  }
96
126
 
97
- function numEnv(raw: string | undefined, fallback: number): number {
98
- if (!raw) return fallback
99
- const value = Number(raw)
100
- return Number.isFinite(value) && value > 0 ? value : fallback
127
+ function listOption(value: unknown, fallback: string[]): string[] {
128
+ if (!Array.isArray(value)) return fallback
129
+ const values = value.filter(
130
+ (item): item is string => typeof item === "string" && item.length > 0,
131
+ )
132
+ return values.length ? values : fallback
101
133
  }