@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/dist/index.js CHANGED
@@ -25,69 +25,103 @@ function configureClientAuth(client) {
25
25
  // lib/config.ts
26
26
  var DEFAULTS = {
27
27
  enabled: true,
28
- intervalMs: 27e4,
29
- // 4.5 min — below the shortest supported cache TTL
30
- windowMs: 33e5,
31
- // 55 min — the Copilot Claude write/read break-even
32
- injectSystemInstruction: true,
33
- pingToken: "~",
34
- revertPing: true,
28
+ intervals: { claude: 285e3, gpt: 168e4 },
29
+ hosts: ["githubcopilot.com"],
30
+ cacheReadFactor: 0.1,
31
+ missFactor: 1,
32
+ maxReplaysPerGap: "auto",
35
33
  includeChildSessions: false,
36
- debug: false,
37
- providerAllowlist: ["copilot"],
38
- modelAllowlist: ["claude", "anthropic", "sonnet", "opus", "haiku", "gpt"]
34
+ replayTimeoutMs: 6e4,
35
+ maxStoredBytes: 67108864,
36
+ debug: false
39
37
  };
40
- function getConfig(options) {
41
- const o = options ?? {};
38
+ var DEPRECATED = [
39
+ "intervalMs",
40
+ "intervalSeconds",
41
+ "windowMs",
42
+ "windowMinutes",
43
+ "revertPing",
44
+ "pingToken",
45
+ "injectSystemInstruction",
46
+ "providerAllowlist",
47
+ "modelAllowlist",
48
+ "claudeBusyWarm",
49
+ "claudeBusyWarmIntervalMs",
50
+ "claudeBusyWarmWindowMs"
51
+ ];
52
+ var warnedDeprecated = /* @__PURE__ */ new Set();
53
+ function getConfig(options, warn = () => {
54
+ }) {
55
+ const option = options ?? {};
42
56
  const env = process.env;
57
+ for (const key of DEPRECATED) {
58
+ if (key in option && !warnedDeprecated.has(key)) {
59
+ warnedDeprecated.add(key);
60
+ warn(`deprecated option ${key} is ignored`);
61
+ }
62
+ }
63
+ const parseJson = (raw) => {
64
+ if (!raw) return void 0;
65
+ try {
66
+ return JSON.parse(raw);
67
+ } catch {
68
+ return void 0;
69
+ }
70
+ };
71
+ const intervals = objectOption(
72
+ option.intervals ?? parseJson(env.OPENCODE_KEEPALIVE_INTERVALS),
73
+ DEFAULTS.intervals
74
+ );
75
+ const hosts = listOption(
76
+ option.hosts ?? parseJson(env.OPENCODE_KEEPALIVE_HOSTS),
77
+ DEFAULTS.hosts
78
+ );
79
+ const maxRaw = option.maxReplaysPerGap ?? env.OPENCODE_KEEPALIVE_MAX_REPLAYS_PER_GAP;
80
+ const maxReplaysPerGap = maxRaw === "auto" ? "auto" : positiveNumber(maxRaw) !== void 0 ? positiveNumber(maxRaw) : DEFAULTS.maxReplaysPerGap;
43
81
  return {
44
- enabled: boolOpt(o.enabled, env.OPENCODE_KEEPALIVE_ENABLED !== "false" && DEFAULTS.enabled),
45
- intervalMs: durationOpt(
46
- o.intervalMs,
47
- o.intervalSeconds,
48
- numEnv(env.OPENCODE_KEEPALIVE_INTERVAL_MS, DEFAULTS.intervalMs)
49
- ),
50
- windowMs: durationOpt(
51
- o.windowMs,
52
- o.windowMinutes ? Number(o.windowMinutes) * 60 : void 0,
53
- numEnv(env.OPENCODE_KEEPALIVE_WINDOW_MS, DEFAULTS.windowMs)
82
+ enabled: boolOption(
83
+ option.enabled,
84
+ env.OPENCODE_KEEPALIVE_ENABLED === void 0 ? DEFAULTS.enabled : env.OPENCODE_KEEPALIVE_ENABLED !== "false"
54
85
  ),
55
- injectSystemInstruction: boolOpt(
56
- o.injectSystemInstruction,
57
- DEFAULTS.injectSystemInstruction
86
+ intervals,
87
+ hosts,
88
+ cacheReadFactor: positiveNumber(option.cacheReadFactor ?? env.OPENCODE_KEEPALIVE_CACHE_READ_FACTOR) ?? DEFAULTS.cacheReadFactor,
89
+ missFactor: positiveNumber(option.missFactor ?? env.OPENCODE_KEEPALIVE_MISS_FACTOR) ?? DEFAULTS.missFactor,
90
+ maxReplaysPerGap,
91
+ includeChildSessions: boolOption(
92
+ option.includeChildSessions,
93
+ env.OPENCODE_KEEPALIVE_INCLUDE_CHILD_SESSIONS === "true" || DEFAULTS.includeChildSessions
58
94
  ),
59
- pingToken: typeof o.pingToken === "string" && o.pingToken.length > 0 ? o.pingToken : DEFAULTS.pingToken,
60
- revertPing: boolOpt(
61
- o.revertPing,
62
- env.OPENCODE_KEEPALIVE_REVERT_PING !== "false" && DEFAULTS.revertPing
63
- ),
64
- includeChildSessions: boolOpt(o.includeChildSessions, DEFAULTS.includeChildSessions),
65
- debug: boolOpt(o.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true"),
66
- providerAllowlist: listOpt(o.providerAllowlist, DEFAULTS.providerAllowlist),
67
- modelAllowlist: listOpt(o.modelAllowlist, DEFAULTS.modelAllowlist)
95
+ replayTimeoutMs: positiveNumber(option.replayTimeoutMs ?? env.OPENCODE_KEEPALIVE_REPLAY_TIMEOUT_MS) ?? DEFAULTS.replayTimeoutMs,
96
+ maxStoredBytes: positiveNumber(option.maxStoredBytes ?? env.OPENCODE_KEEPALIVE_MAX_STORED_BYTES) ?? DEFAULTS.maxStoredBytes,
97
+ debug: boolOption(option.debug, env.OPENCODE_KEEPALIVE_DEBUG === "true")
68
98
  };
69
99
  }
70
- function boolOpt(value, fallback) {
100
+ function boolOption(value, fallback) {
71
101
  return typeof value === "boolean" ? value : fallback;
72
102
  }
73
- function listOpt(value, fallback) {
74
- if (!Array.isArray(value)) return fallback;
75
- const filtered = value.filter((item) => typeof item === "string");
76
- return filtered.length > 0 ? filtered : fallback;
103
+ function positiveNumber(value) {
104
+ const parsed = typeof value === "string" ? Number(value) : value;
105
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
77
106
  }
78
- function durationOpt(ms, seconds, fallback) {
79
- if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms;
80
- if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0)
81
- return seconds * 1e3;
82
- return fallback;
107
+ function objectOption(value, fallback) {
108
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
109
+ const entries = Object.entries(value).flatMap(([key, interval]) => {
110
+ const parsed = positiveNumber(interval);
111
+ return parsed === void 0 ? [] : [[key.toLowerCase(), parsed]];
112
+ });
113
+ return entries.length ? Object.fromEntries(entries) : fallback;
83
114
  }
84
- function numEnv(raw, fallback) {
85
- if (!raw) return fallback;
86
- const value = Number(raw);
87
- return Number.isFinite(value) && value > 0 ? value : fallback;
115
+ function listOption(value, fallback) {
116
+ if (!Array.isArray(value)) return fallback;
117
+ const values = value.filter(
118
+ (item) => typeof item === "string" && item.length > 0
119
+ );
120
+ return values.length ? values : fallback;
88
121
  }
89
122
 
90
123
  // lib/control.ts
124
+ import { randomUUID } from "crypto";
91
125
  import { readFileSync } from "fs";
92
126
  import { mkdir, rename, writeFile } from "fs/promises";
93
127
  import { dirname } from "path";
@@ -96,9 +130,14 @@ import { dirname } from "path";
96
130
  import { createHash } from "crypto";
97
131
  import { homedir } from "os";
98
132
  import { join } from "path";
99
- function stateFilePath(directory) {
100
- const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
101
- return join(stateDirectoryPath(), `state-${key}.json`);
133
+ function stateDirectoryKey(directory) {
134
+ return createHash("sha256").update(directory).digest("hex").slice(0, 16);
135
+ }
136
+ function instanceStateFilePath(directory, pid, instanceId) {
137
+ return join(
138
+ stateDirectoryPath(),
139
+ `state-v2-${stateDirectoryKey(directory)}-${pid}-${instanceId}.json`
140
+ );
102
141
  }
103
142
  function stateDirectoryPath() {
104
143
  return join(
@@ -118,526 +157,971 @@ function logFilePath() {
118
157
  }
119
158
 
120
159
  // lib/control.ts
121
- function readControl(directory) {
160
+ var VERSION = 2;
161
+ var MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
162
+ function readSessionControls(directory, now = Date.now()) {
122
163
  try {
123
- const value = JSON.parse(
124
- readFileSync(controlFilePath(directory), "utf8")
125
- );
126
- if (value.version !== 1 || typeof value.enabled !== "boolean") return void 0;
127
- if (typeof value.updatedAt !== "number") return void 0;
128
- return value;
164
+ const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8"));
165
+ if (value.version !== VERSION || !value.sessions || typeof value.sessions !== "object")
166
+ return { version: VERSION, sessions: {} };
167
+ const sessions = {};
168
+ for (const [id, raw] of Object.entries(value.sessions)) {
169
+ if (!raw || typeof raw !== "object" || typeof raw.enabled !== "boolean" || typeof raw.updatedAt !== "number" || raw.updatedAt < now - MAX_AGE_MS)
170
+ continue;
171
+ sessions[id] = {
172
+ enabled: raw.enabled,
173
+ updatedAt: raw.updatedAt
174
+ };
175
+ }
176
+ return { version: VERSION, sessions };
129
177
  } catch {
130
- return void 0;
178
+ return { version: VERSION, sessions: {} };
131
179
  }
132
180
  }
181
+ var updateQueue = Promise.resolve();
133
182
 
134
- // lib/model.ts
135
- function isEligibleModel(config, providerID, modelID) {
136
- if (!providerID || !modelID) return false;
137
- const provider = providerID.toLowerCase();
138
- const model = modelID.toLowerCase();
139
- const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle));
140
- const modelOk = config.modelAllowlist.some((needle) => model.includes(needle));
141
- return providerOk && modelOk;
142
- }
143
-
144
- // lib/keepalive.ts
145
- var TICK_MS = 15e3;
146
- var JITTER_MS = 15e3;
147
- var CONTROL_POLL_MS = 1e3;
148
- var STATUS_POLL_MS = 2e3;
149
- var PING_STATUS_GRACE_MS = 5e3;
150
- var COLD_AFTER_INTERVALS = 2;
151
- var MESSAGE_LOOKUP_LIMIT = 50;
152
- var KeepaliveEngine = class {
153
- constructor(client, config, store, logger, directory) {
183
+ // lib/replay-warmer.ts
184
+ var STATE_KEY = /* @__PURE__ */ Symbol.for("opencode-cache-keepalive.replay-warmer");
185
+ var RETRY_MS = 1e4;
186
+ var ReplayWarmer = class {
187
+ constructor(client, config, sink, logger, directory, now = Date.now) {
154
188
  this.client = client;
155
189
  this.config = config;
156
- this.store = store;
190
+ this.sink = sink;
157
191
  this.logger = logger;
158
192
  this.directory = directory;
159
- const control = readControl(directory);
160
- this.enabled = control?.enabled ?? config.enabled;
161
- this.controlUpdatedAt = control?.updatedAt ?? 0;
162
- this.store.setEnabled(this.enabled);
193
+ this.now = now;
163
194
  }
164
195
  client;
165
196
  config;
166
- store;
197
+ sink;
167
198
  logger;
168
199
  directory;
169
- timer;
200
+ now;
201
+ runtime;
170
202
  controlTimer;
171
- statusTimer;
172
- controlUpdatedAt = 0;
173
- reconcilingStatus = false;
174
- enabled;
203
+ enabled = false;
175
204
  start() {
176
- if (this.timer) return;
177
- this.timer = setInterval(() => void this.tick(), TICK_MS);
178
- this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS);
179
- this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS);
180
- this.store.persist();
181
- this.logger.info(
182
- `started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.config.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
183
- );
205
+ if (this.runtime) return;
206
+ const runtime = getRuntime(this.now);
207
+ this.enabled = this.config.enabled;
208
+ runtime.instances.set(this, {
209
+ enabled: this.enabled,
210
+ effective: /* @__PURE__ */ new Map(),
211
+ directory: this.directory,
212
+ controls: new Map(
213
+ Object.entries(readSessionControls(this.directory, this.now()).sessions).map(
214
+ ([id, value]) => [id, value.enabled]
215
+ )
216
+ ),
217
+ hosts: new Set(this.config.hosts.map((host) => host.toLowerCase())),
218
+ config: this.config,
219
+ sink: this.sink
220
+ });
221
+ runtime.maxStoredBytes = Math.min(runtime.maxStoredBytes, this.config.maxStoredBytes);
222
+ for (const host of this.config.hosts) runtime.hosts.add(host.toLowerCase());
223
+ runtime.instancesSet.add(this);
224
+ this.runtime = runtime;
225
+ if (!runtime.pruneTimer) {
226
+ runtime.pruneTimer = setInterval(() => pruneOrphanSessions(runtime), 6e4);
227
+ }
228
+ if (runtime.instancesSet.size === 1) {
229
+ globalThis.fetch = ((input, init) => {
230
+ let bodySource;
231
+ let rejection;
232
+ let shouldRecord = false;
233
+ try {
234
+ rejection = cheapEligibilityRejection(runtime, input, init);
235
+ if (rejection) {
236
+ if (isCopilotRequest(input))
237
+ runtime.instancesSet.values().next().value?.debugRecordRejected(rejection);
238
+ } else if (!rejection) {
239
+ shouldRecord = true;
240
+ if (isReplayRequest(input, init) && input instanceof Request && init?.body === void 0) {
241
+ bodySource = input.clone().text();
242
+ void bodySource.catch(() => {
243
+ });
244
+ }
245
+ }
246
+ } catch {
247
+ }
248
+ const result = runtime.originalFetch(input, init);
249
+ if (shouldRecord) {
250
+ queueMicrotask(() => {
251
+ void recordRequest(runtime, input, init, bodySource).catch(() => {
252
+ });
253
+ });
254
+ }
255
+ return result;
256
+ });
257
+ }
258
+ this.controlTimer = setInterval(() => this.syncControl(), 1e3);
184
259
  }
185
- stop() {
186
- if (!this.timer) return;
187
- clearInterval(this.timer);
188
- this.timer = void 0;
260
+ dispose() {
189
261
  if (this.controlTimer) clearInterval(this.controlTimer);
190
262
  this.controlTimer = void 0;
191
- if (this.statusTimer) clearInterval(this.statusTimer);
192
- this.statusTimer = void 0;
193
- }
194
- /**
195
- * True while a ping turn owns the session, so tool calls must be blocked. Becomes
196
- * false as soon as a real user message joins the in-flight ping run: the rest of
197
- * that run is real work and needs its tools.
198
- */
199
- shouldBlockTools(sessionID) {
200
- const s = this.store.get(sessionID);
201
- return !!s?.warming && !s.ping?.interrupted;
202
- }
203
- async onEvent(event) {
204
- const type = event?.type;
205
- if (!type) return;
206
- if (type === "session.deleted") {
207
- const id = event.properties?.sessionID ?? event.properties?.info?.id;
208
- if (id) {
209
- this.store.remove(id);
210
- this.store.persist();
263
+ const runtime = this.runtime;
264
+ this.runtime = void 0;
265
+ if (!runtime) return;
266
+ runtime.instancesSet.delete(this);
267
+ runtime.instances.delete(this);
268
+ for (const [id, owner] of runtime.owners) {
269
+ if (owner !== this) continue;
270
+ const session = runtime.sessions.get(id);
271
+ if (session) {
272
+ this.clearTimerFrom(session);
273
+ session.controller?.abort();
274
+ runtime.storedBytes = Math.max(
275
+ 0,
276
+ runtime.storedBytes - bodyBytes(session.request.body)
277
+ );
278
+ session.request.body = "";
279
+ }
280
+ runtime.owners.delete(id);
281
+ runtime.sessions.delete(id);
282
+ }
283
+ if (runtime.instancesSet.size === 0) {
284
+ if (runtime.pruneTimer) clearInterval(runtime.pruneTimer);
285
+ globalThis.fetch = runtime.originalFetch;
286
+ for (const [id, session] of runtime.sessions) {
287
+ this.clearTimerFrom(session);
288
+ session.controller?.abort();
289
+ runtime.sessions.delete(id);
290
+ }
291
+ delete globalThis[STATE_KEY];
292
+ }
293
+ }
294
+ onEvent(event) {
295
+ const runtime = this.runtime;
296
+ if (!runtime) return;
297
+ const props = event?.properties ?? {};
298
+ const id = props.sessionID ?? props.info?.id ?? props.part?.sessionID;
299
+ const info = props.info ?? props.session;
300
+ if (event?.type === "session.created" && typeof info?.id === "string" && info.parentID)
301
+ runtime.childSessions.add(info.id);
302
+ if (event?.type === "session.deleted" && typeof id === "string")
303
+ runtime.childSessions.delete(id);
304
+ if (typeof id !== "string") return;
305
+ if (event?.type !== "session.deleted" && !this.sessionEnabled(id)) {
306
+ const disabled = runtime.sessions.get(id);
307
+ if (disabled && runtime.owners.get(id) === this) {
308
+ this.disableSession(runtime, id, disabled);
309
+ } else if (!disabled && this.config.enabled && this.runtime?.instances.get(this)?.controls.get(id) === false) {
310
+ runtime.owners.set(id, this);
311
+ this.sink.upsert({
312
+ sessionID: id,
313
+ updatedAt: this.now(),
314
+ enabled: false,
315
+ model: "",
316
+ api: "messages",
317
+ state: "off",
318
+ gapId: 0,
319
+ gapReplays: 0,
320
+ cap: this.cap,
321
+ nextReplayAt: null,
322
+ sessionReplays: 0,
323
+ avoidedTokens: 0,
324
+ resumeHits: 0,
325
+ resumeCount: 0
326
+ });
211
327
  }
212
328
  return;
213
329
  }
214
- if (type === "message.part.updated") {
215
- const part = event.properties?.part;
216
- const sessionID = part?.sessionID ?? event.properties?.sessionID;
217
- if (!sessionID) return;
218
- const session = this.store.get(sessionID);
219
- if (session?.warming) this.notePingRunPart(session, part);
330
+ if (!runtime.owners.has(id)) runtime.owners.set(id, this);
331
+ if (runtime.owners.get(id) !== this) return;
332
+ const session = runtime.sessions.get(id);
333
+ if (!session && event.type === "session.deleted") {
334
+ this.sink.remove(id);
220
335
  return;
221
336
  }
222
- if (type === "message.updated") {
223
- const info = event.properties?.info;
224
- const sessionID = info?.sessionID ?? event.properties?.sessionID;
225
- if (!sessionID) return;
226
- const session = this.store.get(sessionID);
227
- if (session?.warming) {
228
- if (info?.role === "user") this.notePingRunUser(session, info);
229
- return;
337
+ if (!session) return;
338
+ session.touchedAt = this.now();
339
+ const type = event.type;
340
+ if (type === "session.deleted") {
341
+ this.clearTimer(session);
342
+ session.controller?.abort();
343
+ runtime.sessions.delete(id);
344
+ runtime.storedBytes = Math.max(0, runtime.storedBytes - bodyBytes(session.request.body));
345
+ session.request.body = "";
346
+ this.sink.remove(id);
347
+ return;
348
+ }
349
+ if (type === "session.status" || type === "session.idle") {
350
+ const status = props.status?.type ?? "idle";
351
+ if (status === "idle") {
352
+ session.active = false;
353
+ session.busy = false;
354
+ session.runningTools.clear();
355
+ this.updateState(session, "idle");
356
+ this.schedule(id, session, session.request.sentAt + session.request.intervalMs);
357
+ } else if (status === "busy" || status === "running") {
358
+ session.active = session.runningTools.size === 0;
359
+ session.busy = session.runningTools.size > 0;
360
+ if (session.active) this.clearTimer(session);
361
+ this.updateState(session, session.active ? "active" : "busy");
230
362
  }
231
- const eventAt = info?.time?.completed ?? info?.time?.created;
232
- if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
233
- return;
234
- if (info?.role === "user") {
235
- const s = this.store.ensure(sessionID);
236
- if (!isNewUserMessage(s, info)) return;
237
- noteUserMessage(s, info);
238
- s.busy = true;
239
- s.active = false;
240
- this.store.persist();
363
+ return;
364
+ }
365
+ if (type === "message.updated") {
366
+ const info2 = props.info ?? {};
367
+ const interactionType = String(info2.interactionType ?? props.interactionType ?? "");
368
+ if (/agent-session-(name|summary)-generation/i.test(interactionType)) return;
369
+ if (info2.role === "assistant" && (typeof info2.providerID === "string" && !info2.providerID.toLowerCase().replace(/[^a-z]/g, "").includes("copilot") || typeof info2.modelID === "string" && !info2.modelID.toLowerCase().includes(session.request.model.toLowerCase()) && !session.request.model.toLowerCase().includes(info2.modelID.toLowerCase()))) {
370
+ this.invalidateTarget(runtime, session, this.now());
241
371
  return;
242
372
  }
243
- if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
244
- const s = this.store.ensure(sessionID);
245
- s.lastResponseAt = info.time.completed;
246
- this.store.persist();
373
+ if (info2.role === "assistant" && info2.time?.completed === void 0) {
374
+ session.active = true;
375
+ this.clearTimer(session);
376
+ this.updateState(session, "active");
247
377
  }
248
378
  return;
249
379
  }
250
- if (type === "session.status") {
251
- const sessionID = event.properties?.sessionID;
252
- const status = event.properties?.status?.type;
253
- if (!sessionID || !status) return;
254
- const current = this.store.get(sessionID);
255
- if (current?.warming || status !== "idle" && current?.lastPingAt && Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)
256
- return;
257
- if (status === "idle") {
258
- await this.armWindow(sessionID);
259
- return;
380
+ if (type !== "message.part.updated") return;
381
+ const part = props.part;
382
+ if (part?.type === "step-start") {
383
+ session.active = true;
384
+ this.clearTimer(session);
385
+ this.updateState(session, "active");
386
+ } else if (part?.type === "step-finish") {
387
+ session.active = false;
388
+ const cache = part.tokens?.cache ?? {};
389
+ const read = Number(cache.read ?? 0);
390
+ const prompt = Number(part.tokens?.input ?? 0) + read + Number(cache.write ?? 0);
391
+ const completedGap = session.pendingResume;
392
+ if (completedGap) {
393
+ const hit = read >= 0.9 * completedGap.promptPrev;
394
+ const avoidedTokens = hit ? completedGap.promptPrev : 0;
395
+ const resume = {
396
+ at: this.now(),
397
+ read,
398
+ promptPrev: completedGap.promptPrev,
399
+ hit,
400
+ replays: completedGap.replays,
401
+ avoidedTokens,
402
+ replayReadTokens: completedGap.replayReadTokens
403
+ };
404
+ session.entry.resume = resume;
405
+ session.entry.avoidedTokens += avoidedTokens;
406
+ session.entry.resumeCount++;
407
+ if (hit) session.entry.resumeHits++;
408
+ const totals = this.totals;
409
+ totals.avoidedTokens += avoidedTokens;
410
+ totals.replayReadTokens += completedGap.replayReadTokens;
411
+ if (hit) totals.resumeHits++;
412
+ else totals.resumeMisses++;
413
+ this.sink.setTotals(totals);
414
+ this.logger.info(
415
+ `resume session=${id} gap=${session.entry.gapId} hit=${hit} read=${read} prev=${completedGap.promptPrev} replays=${completedGap.replays} avoided=${avoidedTokens} replayRead=${completedGap.replayReadTokens}`
416
+ );
417
+ session.pendingResume = void 0;
418
+ }
419
+ session.previousPrompt = prompt;
420
+ if (session.busy) this.updateState(session, "busy");
421
+ else {
422
+ this.updateState(session, "idle");
423
+ this.schedule(id, session, session.request.sentAt + session.request.intervalMs);
424
+ }
425
+ } else if (part?.type === "tool" && typeof part.callID === "string") {
426
+ if (part.state?.status === "running") session.runningTools.add(part.callID);
427
+ else if (["completed", "error"].includes(part.state?.status))
428
+ session.runningTools.delete(part.callID);
429
+ session.busy = session.runningTools.size > 0;
430
+ if (session.busy) {
431
+ session.active = false;
432
+ this.updateState(session, "busy");
433
+ this.schedule(id, session, session.request.sentAt + session.request.intervalMs);
434
+ } else if (!session.active) {
435
+ this.updateState(session, "idle");
436
+ this.schedule(id, session, session.request.sentAt + session.request.intervalMs);
260
437
  }
261
- const s = this.store.ensure(sessionID);
262
- s.busy = true;
263
- s.active = false;
264
- this.store.persist();
438
+ }
439
+ }
440
+ totals = {
441
+ replays: 0,
442
+ avoidedTokens: 0,
443
+ replayReadTokens: 0,
444
+ resumeHits: 0,
445
+ resumeMisses: 0
446
+ };
447
+ /** @internal Called asynchronously by the process-wide recorder. */
448
+ debugRecordRejected(reason) {
449
+ this.logger.dbg(`record rejected reason=${reason}`);
450
+ }
451
+ /** @internal Called asynchronously by the process-wide recorder. */
452
+ async recordRequest(runtime, input, init, bodySource, selectedBody, selectedParsed) {
453
+ if (!this.runtime) return;
454
+ const request = input instanceof Request ? input : void 0;
455
+ const body = init?.body;
456
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
457
+ const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
458
+ let u;
459
+ try {
460
+ u = new URL(url);
461
+ } catch {
462
+ this.debugRecordRejected("invalid-url");
463
+ return;
464
+ }
465
+ if (!this.config.hosts.some((host) => {
466
+ const allowed = host.toLowerCase();
467
+ const hostname = u.hostname.toLowerCase();
468
+ return hostname === allowed || hostname.endsWith(`.${allowed}`);
469
+ })) {
470
+ if (isCopilotHost(u.hostname)) this.debugRecordRejected("host-not-allowed");
265
471
  return;
266
472
  }
267
- if (type === "session.idle") {
268
- const sessionID = event.properties?.sessionID;
269
- if (!sessionID) return;
270
- if (this.store.get(sessionID)?.warming) return;
271
- await this.armWindow(sessionID);
272
- }
273
- }
274
- /**
275
- * Track a new user message observed while a ping is in flight. Event order alone
276
- * cannot tell the ping's own message from a real prompt racing it, so messages are
277
- * held until their text part arrives (see `notePingRunPart`). Any message seen
278
- * before the ping is sent, or a second new message, is necessarily a real turn.
279
- */
280
- notePingRunUser(s, info) {
281
- const ping = s.ping;
282
- if (!ping || typeof info?.id !== "string") return;
283
- const created = info?.time?.created;
284
- if (typeof created === "number" && created < ping.startedAt) return;
285
- if (info.id === ping.messageID || ping.pending.has(info.id)) return;
286
- if (!ping.sent || ping.messageID) {
287
- this.interruptPing(s, info);
473
+ const headers = new Headers(request?.headers);
474
+ if (init?.headers) new Headers(init.headers).forEach((v, k) => headers.set(k, v));
475
+ if (/agent-session-(name|summary)-generation/i.test(headers.get("X-Interaction-Type") ?? "")) {
476
+ if (isCopilotHost(u.hostname)) this.debugRecordRejected("utility-interaction");
288
477
  return;
289
478
  }
290
- ping.pending.set(info.id, info);
291
- if (ping.pending.size > 1) this.interruptPing(s);
292
- }
293
- /**
294
- * Classify a pending user message by its first text part: the ping token marks
295
- * the ping's own message; any other text is a real turn joining the ping run.
296
- * opencode publishes a user message's parts right after the message itself, before
297
- * the run reaches any tool call.
298
- */
299
- notePingRunPart(s, part) {
300
- const ping = s.ping;
301
- if (!ping || part?.type !== "text" || part?.synthetic) return;
302
- const id = part?.messageID;
303
- if (typeof id !== "string" || !ping.pending.has(id)) return;
304
- const info = ping.pending.get(id);
305
- ping.pending.delete(id);
306
- if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
307
- ping.messageID = id;
479
+ const sessionID = headers.get("X-Session-Id") ?? headers.get("x-session-affinity") ?? headers.get("X-Interaction-Id");
480
+ if (!sessionID) {
481
+ if (isCopilotHost(u.hostname)) this.debugRecordRejected("missing-session-id");
308
482
  return;
309
483
  }
310
- this.interruptPing(s, info);
311
- }
312
- /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
313
- interruptPing(s, info) {
314
- const ping = s.ping;
315
- if (!ping) return;
316
- if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`);
317
- ping.interrupted = true;
318
- if (info) noteUserMessage(s, info);
319
- s.busy = true;
320
- s.active = false;
321
- this.store.persist();
322
- }
323
- async armWindow(sessionID) {
324
- const s = this.store.ensure(sessionID);
325
- if (s.active && !s.busy) return;
326
- if (s.arming) return;
327
- s.arming = true;
484
+ const owner = runtime.owners.get(sessionID);
485
+ if (owner && owner !== this) return;
486
+ if (!runtime.instances.has(this) || !this.enabled || this.sessionEnabled(sessionID) === false)
487
+ return;
488
+ let stringBody;
328
489
  try {
329
- s.busy = false;
330
- await this.resolveSession(s);
331
- if (s.busy || !this.enabled || !s.eligible) {
332
- s.active = false;
333
- this.store.persist();
334
- return;
490
+ stringBody = selectedBody !== void 0 ? selectedBody : typeof body === "string" ? body : body === void 0 && request && isReplayRequest(input, init) ? await (bodySource ?? request.clone().text()) : void 0;
491
+ } catch {
492
+ stringBody = void 0;
493
+ }
494
+ const instance = runtime.instances.get(this);
495
+ if (instance) {
496
+ instance.controls = new Map(
497
+ Object.entries(readSessionControls(this.directory, this.now()).sessions).map(
498
+ ([id, value]) => [id, value.enabled]
499
+ )
500
+ );
501
+ }
502
+ if (!this.runtime || !instance || runtime.owners.has(sessionID) && runtime.owners.get(sessionID) !== this || !this.enabled || this.sessionEnabled(sessionID) === false)
503
+ return;
504
+ const session = runtime.sessions.get(sessionID);
505
+ const sentAt = this.now();
506
+ if (session) session.touchedAt = sentAt;
507
+ if (session) {
508
+ if (session.entry.gapReplays > 0 && session.previousPrompt !== void 0) {
509
+ session.pendingResume = {
510
+ promptPrev: session.previousPrompt,
511
+ replays: session.entry.gapReplays,
512
+ replayReadTokens: session.replayReadTokens
513
+ };
335
514
  }
336
- const now = Date.now();
337
- if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
338
- s.active = false;
339
- this.store.persist();
340
- this.logger.dbg(`not armed ${short(sessionID)} \u2014 no recent real response`);
341
- return;
515
+ this.startGap(session, sentAt);
516
+ session.entry.gapId++;
517
+ }
518
+ if (method !== "POST" || !apiFor(u.pathname)) {
519
+ if (session) this.invalidateTarget(runtime, session, sentAt);
520
+ return;
521
+ }
522
+ if (stringBody === void 0) {
523
+ if (isCopilotHost(u.hostname)) this.debugRecordRejected("unsupported-body-type");
524
+ if (session) {
525
+ this.invalidateTarget(runtime, session, sentAt);
342
526
  }
343
- this.openWindow(s, now);
344
- this.store.persist();
345
- this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
346
- } finally {
347
- s.arming = false;
348
- }
349
- }
350
- /** Start a warm window anchored on the last real response and the last cache touch. */
351
- openWindow(s, now) {
352
- s.idleSince = now;
353
- s.windowEndsAt = s.lastResponseAt + this.config.windowMs;
354
- s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.config.intervalMs);
355
- s.pingsSent = 0;
356
- s.lastPing = void 0;
357
- s.active = true;
358
- }
359
- /** False once the cache has gone too long without a request to still be trusted warm. */
360
- cacheMayBeWarm(s, now) {
361
- const touch = lastCacheTouch(s);
362
- return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.config.intervalMs;
363
- }
364
- async tick() {
365
- if (!this.enabled) return;
366
- const now = Date.now();
367
- for (const s of this.store.all()) {
368
- if (!s.eligible || !s.active || s.busy || s.warming) continue;
369
- if (now >= s.windowEndsAt) {
370
- s.active = false;
371
- this.store.persist();
372
- this.logger.dbg(
373
- `window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) \u2014 cache may cool`
374
- );
375
- continue;
527
+ return;
528
+ }
529
+ let parsed;
530
+ try {
531
+ if (stringBody === void 0) return;
532
+ parsed = selectedBody !== void 0 ? selectedParsed : JSON.parse(stringBody);
533
+ } catch {
534
+ if (isCopilotHost(u.hostname)) this.debugRecordRejected("invalid-json-body");
535
+ if (session) {
536
+ this.invalidateTarget(runtime, session, sentAt);
376
537
  }
377
- if (now < s.nextPingAt) continue;
378
- if (!this.cacheMayBeWarm(s, now)) {
379
- s.active = false;
380
- this.store.persist();
381
- this.logger.dbg(
382
- `window closed ${short(s.sessionID)} \u2014 ping overdue, cache presumed cold`
538
+ return;
539
+ }
540
+ this.recordEligible(runtime, sessionID, url, method, headers, stringBody, parsed, u);
541
+ }
542
+ recordEligible(runtime, sessionID, url, method, headers, stringBody, parsed, u) {
543
+ const child = runtime.childSessions.has(sessionID);
544
+ let session = runtime.sessions.get(sessionID);
545
+ const sentAt = this.now();
546
+ if (session) session.touchedAt = sentAt;
547
+ const eligible = this.sessionEnabled(sessionID) && typeof parsed?.model === "string" && (!child || this.config.includeChildSessions);
548
+ const model = typeof parsed?.model === "string" ? parsed.model.toLowerCase() : "";
549
+ const match = Object.entries(this.config.intervals).find(
550
+ ([key]) => model.includes(key.toLowerCase())
551
+ );
552
+ const api = apiFor(u.pathname);
553
+ if (!eligible || !match || !api) {
554
+ if (isCopilotHost(u.hostname))
555
+ this.debugRecordRejected(
556
+ !this.sessionEnabled(sessionID) ? "session-disabled" : child && !this.config.includeChildSessions ? "child-session" : typeof parsed?.model !== "string" ? "missing-model" : !match ? "model-not-configured" : "unsupported-path"
383
557
  );
384
- continue;
558
+ if (session) {
559
+ this.invalidateTarget(runtime, session, sentAt);
385
560
  }
386
- void this.firePing(s.sessionID);
561
+ return;
387
562
  }
388
- }
389
- async firePing(sessionID) {
390
- const s = this.store.get(sessionID);
391
- if (!s || s.warming || s.busy || !s.active) return;
392
- const ping = {
393
- startedAt: Date.now(),
394
- sent: false,
395
- pending: /* @__PURE__ */ new Map(),
396
- interrupted: false
563
+ runtime.instances.get(this).effective.set(sessionID, true);
564
+ if (!runtime.loggedApis.has(api)) {
565
+ runtime.loggedApis.add(api);
566
+ this.logger.info(`record api=${api} host=${u.hostname}${u.pathname}`);
567
+ }
568
+ if (session) {
569
+ session.entry.gapReplays = 0;
570
+ session.entry.resume = void 0;
571
+ session.entry.lastReplay = void 0;
572
+ session.entry.stopReason = void 0;
573
+ session.entry.stopStatus = void 0;
574
+ session.entry.state = "active";
575
+ session.entry.enabled = true;
576
+ session.entry.model = parsed.model;
577
+ session.entry.api = api;
578
+ session.entry.nextReplayAt = null;
579
+ session.entry.updatedAt = sentAt;
580
+ session.active = true;
581
+ session.busy = false;
582
+ session.retries = 0;
583
+ }
584
+ runtime.owners.set(sessionID, this);
585
+ const oldSession = runtime.sessions.get(sessionID);
586
+ if (oldSession) {
587
+ runtime.storedBytes = Math.max(
588
+ 0,
589
+ runtime.storedBytes - bodyBytes(oldSession.request.body)
590
+ );
591
+ oldSession.request = {
592
+ url,
593
+ method,
594
+ headers,
595
+ body: stringBody,
596
+ model: parsed.model,
597
+ api,
598
+ sentAt,
599
+ intervalMs: match[1]
600
+ };
601
+ runtime.storedBytes += bodyBytes(stringBody);
602
+ oldSession.child = child;
603
+ this.publish(oldSession);
604
+ this.enforceStoredBudget(runtime);
605
+ return;
606
+ }
607
+ session = {
608
+ request: {
609
+ url,
610
+ method,
611
+ headers,
612
+ body: stringBody,
613
+ model: parsed.model,
614
+ api,
615
+ sentAt,
616
+ intervalMs: match[1]
617
+ },
618
+ entry: {
619
+ sessionID,
620
+ updatedAt: sentAt,
621
+ enabled: true,
622
+ model: parsed.model,
623
+ api,
624
+ state: "active",
625
+ gapId: 1,
626
+ gapReplays: 0,
627
+ cap: this.cap,
628
+ nextReplayAt: null,
629
+ sessionReplays: 0,
630
+ avoidedTokens: 0,
631
+ resumeHits: 0,
632
+ resumeCount: 0
633
+ },
634
+ runningTools: /* @__PURE__ */ new Set(),
635
+ active: true,
636
+ busy: false,
637
+ realRequests: 1,
638
+ retries: 0,
639
+ child,
640
+ replayReadTokens: 0,
641
+ touchedAt: sentAt
397
642
  };
398
- s.warming = true;
399
- s.ping = ping;
400
- this.store.persist();
643
+ runtime.sessions.set(sessionID, session);
644
+ runtime.owners.set(sessionID, this);
645
+ runtime.storedBytes += bodyBytes(stringBody);
646
+ this.publish(session);
647
+ this.enforceStoredBudget(runtime);
648
+ }
649
+ startGap(session, sentAt, model) {
650
+ const runtime = this.runtime;
651
+ session.controller?.abort();
652
+ session.controller = void 0;
653
+ this.clearTimer(session);
654
+ runtime.storedBytes = Math.max(0, runtime.storedBytes - bodyBytes(session.request.body));
655
+ session.request.body = "";
656
+ session.entry.gapReplays = 0;
657
+ session.entry.resume = void 0;
658
+ session.entry.lastReplay = void 0;
659
+ session.entry.stopReason = void 0;
660
+ session.entry.stopStatus = void 0;
661
+ session.entry.nextReplayAt = null;
662
+ session.replayReadTokens = 0;
663
+ session.entry.updatedAt = sentAt;
664
+ session.touchedAt = sentAt;
665
+ session.entry.model = model ?? session.entry.model;
666
+ session.active = false;
667
+ session.busy = false;
668
+ session.retries = 0;
669
+ }
670
+ invalidateTarget(runtime, session, at) {
671
+ this.clearTimer(session);
672
+ session.controller?.abort();
673
+ session.controller = void 0;
674
+ runtime.storedBytes = Math.max(0, runtime.storedBytes - bodyBytes(session.request.body));
675
+ session.request.body = "";
676
+ session.entry.state = this.sessionEnabled(session.entry.sessionID) ? "idle" : "off";
677
+ session.entry.nextReplayAt = null;
678
+ session.entry.updatedAt = at;
679
+ this.publish(session);
680
+ }
681
+ enforceStoredBudget(runtime) {
682
+ while (runtime.storedBytes > runtime.maxStoredBytes) {
683
+ const oldestID = [...runtime.sessions.keys()].filter((id) => runtime.sessions.get(id).request.body.length > 0).sort(
684
+ (a, b) => runtime.sessions.get(a).touchedAt - runtime.sessions.get(b).touchedAt
685
+ )[0];
686
+ if (!oldestID) break;
687
+ const session = runtime.sessions.get(oldestID);
688
+ this.clearTimer(session);
689
+ session.controller?.abort();
690
+ runtime.storedBytes -= bodyBytes(session.request.body);
691
+ session.request.body = "";
692
+ runtime.owners.get(oldestID)?.logger.info(`evict session=${oldestID}`);
693
+ session.entry.nextReplayAt = null;
694
+ session.entry.state = "idle";
695
+ session.entry.updatedAt = this.now();
696
+ runtime.owners.get(oldestID)?.publish(session);
697
+ }
698
+ }
699
+ get cap() {
700
+ return this.config.maxReplaysPerGap === "auto" ? Math.floor(
701
+ (this.config.missFactor - this.config.cacheReadFactor) / this.config.cacheReadFactor
702
+ ) : this.config.maxReplaysPerGap;
703
+ }
704
+ schedule(id, session, at) {
705
+ this.clearTimer(session);
706
+ if (this.runtime?.owners.get(id) !== this || !session.entry.enabled || session.active || session.entry.state === "stopped" || !session.request.body)
707
+ return;
708
+ session.entry.nextReplayAt = at;
709
+ this.publish(session);
710
+ this.logger.dbg(
711
+ `schedule session=${id} state=${session.busy ? "busy" : "idle"} next=${new Date(at).toISOString()}`
712
+ );
713
+ session.timer = setTimeout(
714
+ () => void this.replay(id, session),
715
+ Math.max(0, at - this.now())
716
+ );
717
+ }
718
+ async replay(id, session) {
719
+ const runtime = this.runtime;
720
+ if (!runtime || runtime.owners.get(id) !== this || !session.entry.enabled || runtime.sessions.get(id) !== session || session.active)
721
+ return;
722
+ const sentAt = this.now();
723
+ session.request.sentAt = sentAt;
724
+ session.touchedAt = sentAt;
725
+ const controller = new AbortController();
726
+ session.controller = controller;
727
+ session.entry.nextReplayAt = null;
728
+ this.updateState(session, session.busy ? "busy" : "idle");
729
+ let timeout;
730
+ let latencyMs = 0;
731
+ let status = 0;
401
732
  try {
402
- if (!await this.isIdleOnServer(sessionID)) {
403
- s.busy = true;
404
- s.active = false;
405
- this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 session busy`);
733
+ const timeoutPromise = new Promise((_, reject) => {
734
+ timeout = setTimeout(() => {
735
+ controller.abort();
736
+ reject(new Error("timeout"));
737
+ }, this.config.replayTimeoutMs);
738
+ });
739
+ const response = await Promise.race([
740
+ runtime.originalFetch(session.request.url, {
741
+ method: session.request.method,
742
+ headers: new Headers(session.request.headers),
743
+ body: session.request.body,
744
+ signal: controller.signal
745
+ }),
746
+ timeoutPromise
747
+ ]);
748
+ status = response.status;
749
+ if ([400, 401, 403].includes(status)) {
750
+ session.entry.lastReplay = { at: sentAt, status, latencyMs: this.now() - sentAt };
751
+ this.logger.info(replayLogLine(session, session.entry.lastReplay, "http-status"));
752
+ this.stopSession(id, session, "rejected-4xx", status);
406
753
  return;
407
754
  }
408
- const request = s.request ?? await this.lookupRequest(s);
409
- if (!request) {
410
- s.active = false;
411
- this.logger.dbg(`ping ${short(sessionID)} skipped \u2014 last real request unknown`);
755
+ if (!response.ok) {
756
+ session.entry.lastReplay = { at: sentAt, status, latencyMs: this.now() - sentAt };
757
+ this.logger.info(replayLogLine(session, session.entry.lastReplay, "http-status"));
758
+ this.retryOrLapse(id, session, sentAt, void 0, status);
412
759
  return;
413
760
  }
414
- if (ping.interrupted || s.busy || !s.active || !this.enabled) return;
415
- ping.sent = true;
416
- s.pingsSent += 1;
417
- this.store.persist();
418
- const res = await this.client.session.prompt({
419
- path: { id: sessionID },
420
- body: {
421
- parts: [{ type: "text", text: this.config.pingToken }],
422
- ...request.agent ? { agent: request.agent } : {},
423
- model: { providerID: request.providerID, modelID: request.modelID },
424
- ...request.variant ? { variant: request.variant } : {}
425
- }
426
- });
427
- const info = res?.data?.info ?? res?.info;
428
- if (ping.interrupted) {
429
- const completed = info?.time?.completed;
430
- if (typeof completed === "number") s.lastResponseAt = completed;
431
- this.logger.dbg(
432
- `ping ${short(sessionID)} absorbed a real turn \u2014 kept, not reverted`
433
- );
761
+ const result = await Promise.race([
762
+ readAbortPoint(response, session.request.api, controller),
763
+ timeoutPromise
764
+ ]);
765
+ latencyMs = this.now() - sentAt;
766
+ if (session.controller !== controller || runtime.sessions.get(id) !== session) return;
767
+ const { abortEvent, ...usage } = result;
768
+ const last = { at: sentAt, status, latencyMs, ...usage };
769
+ session.entry.lastReplay = last;
770
+ session.entry.gapReplays++;
771
+ session.entry.sessionReplays++;
772
+ session.replayReadTokens += result.cacheRead ?? session.previousPrompt ?? 0;
773
+ this.totals.replays++;
774
+ this.sink.setTotals(this.totals);
775
+ session.retries = 0;
776
+ this.logger.info(replayLogLine(session, last, abortEvent));
777
+ if (session.request.api === "messages" && result.cacheRead === 0 && (result.cacheWrite ?? 0) > 0) {
778
+ session.entry.gapReplays--;
779
+ this.stopSession(id, session, "lapsed");
434
780
  return;
435
781
  }
436
- const cache = info?.tokens?.cache ?? {};
437
- const record = {
438
- at: Date.now(),
439
- hit: Number(cache.read ?? 0) > 0,
440
- input: Number(info?.tokens?.input ?? 0),
441
- cacheRead: Number(cache.read ?? 0),
442
- cacheWrite: Number(cache.write ?? 0),
443
- output: Number(info?.tokens?.output ?? 0)
444
- };
445
- s.lastPing = record;
446
- this.logger.dbg(
447
- `ping ${short(sessionID)} ${record.hit ? "HIT" : "MISS"} in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`
782
+ if (session.entry.gapReplays >= session.entry.cap) {
783
+ this.stopSession(id, session, "cap");
784
+ return;
785
+ }
786
+ this.schedule(id, session, sentAt + session.request.intervalMs);
787
+ } catch (error) {
788
+ if (runtime.sessions.get(id) !== session || session.controller !== controller) return;
789
+ const timeoutHit = this.now() - sentAt >= this.config.replayTimeoutMs;
790
+ session.entry.lastReplay = { at: sentAt, status: 0, latencyMs: this.now() - sentAt };
791
+ this.logger.info(
792
+ replayLogLine(
793
+ session,
794
+ session.entry.lastReplay,
795
+ timeoutHit ? "timeout" : "network"
796
+ )
448
797
  );
449
- if (this.config.revertPing) await this.revertPing(sessionID, ping);
450
- } catch (err) {
451
- this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
798
+ this.retryOrLapse(id, session, sentAt, timeoutHit ? "timeout" : "network");
452
799
  } finally {
453
- s.warming = false;
454
- s.ping = void 0;
455
- const now = Date.now();
456
- if (ping.sent) {
457
- s.lastPingAt = now;
458
- const next = s.nextPingAt + jitter(this.config.intervalMs);
459
- s.nextPingAt = next > now ? next : now + jitter(this.config.intervalMs);
460
- }
461
- this.store.persist();
462
- if (ping.sent && ping.interrupted) {
463
- s.busy = false;
464
- s.active = false;
465
- void this.armWindow(sessionID);
466
- }
800
+ if (timeout) clearTimeout(timeout);
801
+ controller.abort();
802
+ if (session.controller === controller) session.controller = void 0;
467
803
  }
468
804
  }
469
- /** True only when the server positively reports the session idle. */
470
- async isIdleOnServer(sessionID) {
471
- try {
472
- const res = await this.client.session.status();
473
- const statuses = res?.data ?? res ?? {};
474
- return (statuses?.[sessionID]?.type ?? "idle") === "idle";
475
- } catch (err) {
476
- this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err));
477
- return false;
478
- }
479
- }
480
- /** Recover the last real turn's request settings when no user event was observed. */
481
- async lookupRequest(s) {
482
- const messages = await this.recentMessages(s.sessionID);
483
- if (!messages) return void 0;
484
- for (let i = messages.length - 1; i >= 0; i--) {
485
- const m = messages[i];
486
- if (m?.info?.role !== "user" || this.isPingMessage(m)) continue;
487
- const request = requestFrom(m.info);
488
- if (request) {
489
- s.request = request;
490
- return request;
491
- }
492
- }
493
- return void 0;
805
+ retryOrLapse(id, session, sentAt, reason, status = 0) {
806
+ session.entry.lastReplay = { at: sentAt, status, latencyMs: this.now() - sentAt };
807
+ if (session.retries++ === 0) this.schedule(id, session, sentAt + RETRY_MS);
808
+ else this.stopSession(id, session, "lapsed");
809
+ if (reason) this.logger.warn(`replay ${reason} session=${id}`);
494
810
  }
495
- async recentMessages(sessionID) {
496
- try {
497
- const res = await this.client.session.messages({
498
- path: { id: sessionID },
499
- query: { limit: MESSAGE_LOOKUP_LIMIT }
500
- });
501
- const messages = res?.data ?? res;
502
- return Array.isArray(messages) ? messages : void 0;
503
- } catch (err) {
504
- this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err));
505
- return void 0;
811
+ stopSession(id, session, reason, status) {
812
+ if (session.stopLoggedGap !== session.entry.gapId) {
813
+ session.stopLoggedGap = session.entry.gapId;
814
+ this.logger.info(
815
+ `stop session=${id} reason=${reason}${status === void 0 ? "" : ` status=${status}`}`
816
+ );
506
817
  }
818
+ this.clearTimer(session);
819
+ session.controller?.abort();
820
+ session.entry.state = "stopped";
821
+ session.entry.stopReason = reason;
822
+ session.entry.stopStatus = status;
823
+ session.entry.nextReplayAt = null;
824
+ session.entry.updatedAt = this.now();
825
+ if (this.runtime)
826
+ this.runtime.storedBytes = Math.max(
827
+ 0,
828
+ this.runtime.storedBytes - bodyBytes(session.request.body)
829
+ );
830
+ session.request.body = "";
831
+ session.request.headers = new Headers();
832
+ this.publish(session);
507
833
  }
508
- isPingMessage(m) {
509
- const text = (m?.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
510
- return text === this.config.pingToken;
511
- }
512
- /**
513
- * Remove the `~` user + `~` assistant turn so it never enters real context.
514
- * Revert drops the target and everything after it (and rolls back file
515
- * snapshots), so it is skipped unless only the ping's own replies follow.
516
- */
517
- async revertPing(sessionID, ping) {
518
- const messages = await this.recentMessages(sessionID);
519
- if (!messages) return;
520
- let index = -1;
521
- for (let i = 0; i < messages.length; i++) {
522
- const m = messages[i];
523
- if (m?.info?.role !== "user") continue;
524
- if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
525
- index = i;
526
- }
527
- if (index < 0) {
528
- this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
529
- return;
530
- }
531
- const target = messages[index].info.id;
532
- const foreign = messages.slice(index + 1).some((m) => m?.info?.role !== "assistant" || m?.info?.parentID !== target);
533
- if (foreign) {
534
- this.logger.warn(`ping ${short(sessionID)} not reverted \u2014 other messages followed it`);
535
- return;
834
+ updateState(session, state) {
835
+ const enabled = this.sessionEnabled(session.entry.sessionID);
836
+ session.entry.state = enabled ? state : "off";
837
+ session.entry.enabled = enabled;
838
+ session.entry.updatedAt = this.now();
839
+ session.entry.cap = this.cap;
840
+ this.publish(session);
841
+ }
842
+ publish(session) {
843
+ this.sink.upsert({ ...session.entry });
844
+ }
845
+ clearTimer(session) {
846
+ if (!session) return;
847
+ if (session.timer) clearTimeout(session.timer);
848
+ session.timer = void 0;
849
+ session.entry.nextReplayAt = null;
850
+ }
851
+ clearTimerFrom(session) {
852
+ if (session.timer) clearTimeout(session.timer);
853
+ session.timer = void 0;
854
+ session.entry.nextReplayAt = null;
855
+ }
856
+ sessionEnabled(sessionID) {
857
+ const instance = this.runtime?.instances.get(this);
858
+ return this.config.enabled && instance?.controls.get(sessionID) !== false;
859
+ }
860
+ disableSession(runtime, id, session) {
861
+ const instance = runtime.instances.get(this);
862
+ instance.effective.set(id, false);
863
+ this.clearTimer(session);
864
+ session.controller?.abort();
865
+ runtime.storedBytes = Math.max(0, runtime.storedBytes - bodyBytes(session.request.body));
866
+ session.request.body = "";
867
+ session.entry.stopReason = "disabled";
868
+ session.entry.state = "off";
869
+ session.entry.enabled = false;
870
+ session.entry.nextReplayAt = null;
871
+ session.entry.updatedAt = this.now();
872
+ if (session.stopLoggedGap !== session.entry.gapId) {
873
+ session.stopLoggedGap = session.entry.gapId;
874
+ this.logger.info(`stop session=${id} reason=disabled`);
536
875
  }
537
- try {
538
- await this.client.session.revert({
539
- path: { id: sessionID },
540
- body: { messageID: target }
541
- });
542
- } catch (err) {
543
- this.logger.warn(`ping ${short(sessionID)} revert failed`, errText(err));
876
+ this.publish(session);
877
+ }
878
+ syncControl() {
879
+ const runtime = this.runtime;
880
+ if (!runtime) return;
881
+ const instance = runtime.instances.get(this);
882
+ const controls = readSessionControls(this.directory, this.now()).sessions;
883
+ instance.controls = new Map(
884
+ Object.entries(controls).map(([id, value]) => [id, value.enabled])
885
+ );
886
+ for (const [id, session] of runtime.sessions) {
887
+ if (runtime.owners.get(id) !== this) continue;
888
+ const enabled = this.sessionEnabled(id);
889
+ const previous = instance.effective.get(id) ?? session.entry.enabled;
890
+ instance.effective.set(id, enabled);
891
+ if (previous === enabled) continue;
892
+ if (!enabled) {
893
+ this.disableSession(runtime, id, session);
894
+ } else {
895
+ session.entry.stopReason = void 0;
896
+ session.entry.state = session.active ? "active" : session.busy ? "busy" : "idle";
897
+ session.entry.enabled = true;
898
+ session.entry.updatedAt = this.now();
899
+ this.publish(session);
900
+ }
544
901
  }
545
902
  }
546
- isRecentPing(m, ping) {
547
- return Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2e3 && this.isPingMessage(m);
903
+ };
904
+ function getRuntime(now) {
905
+ const target = globalThis;
906
+ if (target[STATE_KEY]) return target[STATE_KEY];
907
+ const runtime = {
908
+ originalFetch: globalThis.fetch,
909
+ sessions: /* @__PURE__ */ new Map(),
910
+ owners: /* @__PURE__ */ new Map(),
911
+ instancesSet: /* @__PURE__ */ new Set(),
912
+ childSessions: /* @__PURE__ */ new Set(),
913
+ loggedApis: /* @__PURE__ */ new Set(),
914
+ now,
915
+ maxStoredBytes: Number.MAX_SAFE_INTEGER,
916
+ storedBytes: 0,
917
+ hosts: /* @__PURE__ */ new Set(),
918
+ instances: /* @__PURE__ */ new Map()
919
+ };
920
+ target[STATE_KEY] = runtime;
921
+ return runtime;
922
+ }
923
+ async function recordRequest(runtime, input, init, bodySource) {
924
+ const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
925
+ if (init?.headers && input instanceof Request)
926
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
927
+ const id = headers.get("X-Session-Id") ?? headers.get("x-session-affinity") ?? headers.get("X-Interaction-Id");
928
+ if (!id) return;
929
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
930
+ const rawUrl = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
931
+ let url;
932
+ try {
933
+ url = new URL(rawUrl);
934
+ } catch {
935
+ return;
548
936
  }
549
- async resolveSession(s) {
937
+ const mapped = runtime.owners.get(id);
938
+ let owner;
939
+ if (mapped) {
940
+ const state = runtime.instances.get(mapped);
941
+ if (!state?.enabled || state.controls.get(id) === false) return;
942
+ owner = mapped;
943
+ } else {
944
+ const hostname = url.hostname.toLowerCase();
945
+ const eligiblePath = method === "POST" && !!apiFor(url.pathname);
946
+ const candidates = [...runtime.instances].filter(
947
+ ([, state]) => state.enabled && state.controls.get(id) !== false && (eligiblePath || runtime.sessions.has(id)) && [...state.hosts].some(
948
+ (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
949
+ )
950
+ );
951
+ if (!candidates.length) return;
952
+ let stringBody;
550
953
  try {
551
- const res = await this.client.session.get({ path: { id: s.sessionID } });
552
- const info = res?.data ?? res;
553
- if (info?.parentID && !this.config.includeChildSessions) {
554
- s.eligible = false;
555
- s.modelLabel = info?.model?.id ?? "child";
556
- return;
557
- }
558
- s.modelLabel = info?.model?.id ?? "unknown";
559
- s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id);
560
- } catch (err) {
561
- s.eligible = false;
562
- this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err));
563
- }
564
- }
565
- pollControl() {
566
- const control = readControl(this.directory);
567
- if (!control || control.updatedAt <= this.controlUpdatedAt) return;
568
- this.controlUpdatedAt = control.updatedAt;
569
- if (control.enabled === this.enabled) return;
570
- this.enabled = control.enabled;
571
- this.store.setEnabled(control.enabled);
572
- const now = Date.now();
573
- for (const session of this.store.all()) {
574
- if (!control.enabled) {
575
- session.active = false;
576
- continue;
577
- }
578
- if (!session.eligible || session.busy || session.warming) continue;
579
- if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue;
580
- this.openWindow(session, now);
581
- }
582
- this.store.persist();
583
- this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`);
584
- }
585
- /** Recover when a session.status idle event is missed by reconciling with the API. */
586
- async reconcileStatus() {
587
- if (this.reconcilingStatus) return;
588
- const busy = this.store.all().filter((session) => session.busy && !session.warming);
589
- if (busy.length === 0) return;
590
- this.reconcilingStatus = true;
954
+ stringBody = typeof init?.body === "string" ? init.body : init?.body === void 0 && input instanceof Request ? await (bodySource ?? input.clone().text()) : void 0;
955
+ } catch {
956
+ stringBody = void 0;
957
+ }
958
+ let parsed;
591
959
  try {
592
- const res = await this.client.session.status();
593
- const statuses = res?.data ?? res ?? {};
594
- for (const session of busy) {
595
- const status = statuses?.[session.sessionID]?.type ?? "idle";
596
- if (status === "idle") await this.armWindow(session.sessionID);
597
- }
598
- } catch (err) {
599
- this.logger.dbg("status reconciliation failed", errText(err));
600
- } finally {
601
- this.reconcilingStatus = false;
960
+ if (stringBody !== void 0) parsed = JSON.parse(stringBody);
961
+ } catch {
962
+ parsed = void 0;
963
+ }
964
+ const concurrentOwner = runtime.owners.get(id);
965
+ if (concurrentOwner) {
966
+ const state = runtime.instances.get(concurrentOwner);
967
+ if (!state?.enabled || state.controls.get(id) === false) return;
968
+ await concurrentOwner.recordRequest(runtime, input, init, void 0, stringBody, parsed);
969
+ return;
602
970
  }
971
+ const child = runtime.childSessions.has(id);
972
+ const liveCandidates = candidates.filter(
973
+ ([instance, state]) => runtime.instances.has(instance) && state.enabled && state.controls.get(id) !== false
974
+ );
975
+ if (!liveCandidates.length) return;
976
+ const fullyEligible = liveCandidates.filter(([, state]) => {
977
+ if (method !== "POST" || !apiFor(url.pathname)) return false;
978
+ if (child && !state.config.includeChildSessions) return false;
979
+ const model = typeof parsed?.model === "string" ? parsed.model.toLowerCase() : "";
980
+ return Object.entries(state.config.intervals).some(
981
+ ([key]) => model.includes(key.toLowerCase())
982
+ ) && (!child || state.config.includeChildSessions);
983
+ });
984
+ if (!fullyEligible.length) return;
985
+ owner = fullyEligible[0][0];
986
+ runtime.owners.set(id, owner);
987
+ await owner.recordRequest(runtime, input, init, void 0, stringBody, parsed);
988
+ return;
603
989
  }
604
- };
605
- function lastCacheTouch(s) {
606
- return Math.max(s.lastResponseAt, s.lastPingAt ?? 0);
990
+ if (!runtime.instances.has(owner) || runtime.owners.get(id) !== owner) return;
991
+ await owner.recordRequest(runtime, input, init, bodySource);
607
992
  }
608
- function isNewUserMessage(s, info) {
609
- if (typeof info?.id === "string" && info.id === s.lastUserID) return false;
610
- const created = info?.time?.created;
611
- return typeof created !== "number" || created >= (s.lastUserAt ?? 0);
993
+ function bodyBytes(body) {
994
+ return new TextEncoder().encode(body).byteLength;
612
995
  }
613
- function noteUserMessage(s, info) {
614
- if (typeof info?.id === "string") s.lastUserID = info.id;
615
- const created = info?.time?.created;
616
- if (typeof created === "number") s.lastUserAt = created;
617
- const request = requestFrom(info);
618
- if (request) s.request = request;
996
+ function pruneOrphanSessions(runtime) {
997
+ const cutoff = runtime.now() - 60 * 6e4;
998
+ for (const [id, session] of runtime.sessions) {
999
+ if (session.request.body || session.touchedAt >= cutoff) continue;
1000
+ session.timer && clearTimeout(session.timer);
1001
+ session.controller?.abort();
1002
+ const owner = runtime.owners.get(id);
1003
+ if (owner) runtime.instances.get(owner)?.sink.remove(id);
1004
+ runtime.owners.delete(id);
1005
+ runtime.sessions.delete(id);
1006
+ }
619
1007
  }
620
- function requestFrom(info) {
621
- const model = info?.model;
622
- const providerID = model?.providerID;
623
- const modelID = model?.modelID ?? model?.id;
624
- if (typeof providerID !== "string" || typeof modelID !== "string") return void 0;
625
- const variant = model?.variant ?? info?.variant;
626
- return {
627
- agent: typeof info?.agent === "string" ? info.agent : void 0,
628
- providerID,
629
- modelID,
630
- variant: typeof variant === "string" && variant !== "default" ? variant : void 0
631
- };
1008
+ function cheapEligibilityRejection(runtime, input, init) {
1009
+ const request = input instanceof Request ? input : void 0;
1010
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
1011
+ const rawUrl = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
1012
+ let url;
1013
+ try {
1014
+ url = new URL(rawUrl);
1015
+ } catch {
1016
+ return "invalid-url";
1017
+ }
1018
+ const copilotHost = isCopilotHost(url.hostname);
1019
+ const headers = new Headers(request?.headers);
1020
+ if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value));
1021
+ const id = headers.get("X-Session-Id") ?? headers.get("x-session-affinity") ?? headers.get("X-Interaction-Id");
1022
+ if (!id) return "missing-session-id";
1023
+ if (/agent-session-(name|summary)-generation/i.test(headers.get("X-Interaction-Type") ?? ""))
1024
+ return "utility-interaction";
1025
+ const hostname = url.hostname.toLowerCase();
1026
+ const owner = runtime.owners.get(id);
1027
+ const allowedHost = owner ? (() => {
1028
+ const state = runtime.instances.get(owner);
1029
+ return !!state?.enabled && state.controls.get(id) !== false && [...state.hosts].some(
1030
+ (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
1031
+ );
1032
+ })() : [...runtime.instances.values()].some(
1033
+ (state) => state.enabled && state.controls.get(id) !== false && [...state.hosts].some(
1034
+ (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
1035
+ )
1036
+ );
1037
+ if (!allowedHost) return "host-not-allowed";
1038
+ if (runtime.owners.has(id) || method === "POST" && !!apiFor(url.pathname)) return void 0;
1039
+ if (!copilotHost) return "ineligible";
1040
+ return method !== "POST" ? "method-not-post" : "unsupported-path";
1041
+ }
1042
+ function isReplayRequest(input, init) {
1043
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
1044
+ const rawUrl = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
1045
+ try {
1046
+ return method === "POST" && !!apiFor(new URL(rawUrl).pathname);
1047
+ } catch {
1048
+ return false;
1049
+ }
632
1050
  }
633
- function short(sessionID) {
634
- return sessionID.slice(-6);
1051
+ function isCopilotHost(hostname) {
1052
+ const host = hostname.toLowerCase();
1053
+ return host === "githubcopilot.com" || host.endsWith(".githubcopilot.com") || host.startsWith("copilot-api.");
635
1054
  }
636
- function jitter(base) {
637
- return Math.max(1e3, base + Math.floor((Math.random() * 2 - 1) * JITTER_MS));
1055
+ function isCopilotRequest(input) {
1056
+ const rawUrl = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
1057
+ try {
1058
+ return isCopilotHost(new URL(rawUrl).hostname);
1059
+ } catch {
1060
+ return false;
1061
+ }
638
1062
  }
639
- function errText(err) {
640
- return err instanceof Error ? err.message : String(err);
1063
+ function apiFor(path) {
1064
+ if (/\/v1\/messages\/?$/.test(path)) return "messages";
1065
+ if (/^\/(?:v1\/)?responses\/?$/.test(path)) return "responses";
1066
+ if (/\/chat\/completions\/?$/.test(path)) return "chat";
1067
+ }
1068
+ async function readAbortPoint(response, api, controller) {
1069
+ const reader = response.body?.getReader();
1070
+ if (!reader) {
1071
+ controller.abort();
1072
+ return { abortEvent: "first-chunk" };
1073
+ }
1074
+ let buffer = "";
1075
+ try {
1076
+ while (true) {
1077
+ const { value, done } = await reader.read();
1078
+ if (done) break;
1079
+ if (!value) continue;
1080
+ if (api === "chat") {
1081
+ controller.abort();
1082
+ return { abortEvent: "first-chunk" };
1083
+ }
1084
+ buffer += new TextDecoder().decode(value, { stream: true });
1085
+ const events = buffer.split(/\r?\n\r?\n/);
1086
+ buffer = events.pop() ?? "";
1087
+ for (const event of events) {
1088
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
1089
+ if (!data || data === "[DONE]") continue;
1090
+ let parsed;
1091
+ try {
1092
+ parsed = JSON.parse(data);
1093
+ } catch {
1094
+ continue;
1095
+ }
1096
+ if (api === "responses" && ["response.created", "response.in_progress"].includes(parsed.type))
1097
+ continue;
1098
+ if (api === "messages") {
1099
+ if (parsed.type !== "message_start") continue;
1100
+ const usage = parsed.message?.usage ?? {};
1101
+ controller.abort();
1102
+ return {
1103
+ abortEvent: "message_start",
1104
+ cacheRead: numeric(usage.cache_read_input_tokens),
1105
+ cacheWrite: numeric(usage.cache_creation_input_tokens)
1106
+ };
1107
+ }
1108
+ controller.abort();
1109
+ return { abortEvent: String(parsed.type ?? "first-event") };
1110
+ }
1111
+ }
1112
+ } finally {
1113
+ controller.abort();
1114
+ await reader.cancel().catch(() => {
1115
+ });
1116
+ }
1117
+ return { abortEvent: "stream-end" };
1118
+ }
1119
+ function replayLogLine(session, result, abortEvent) {
1120
+ const usage = result.cacheRead === void 0 && result.cacheWrite === void 0 ? "" : ` cacheRead=${result.cacheRead ?? 0} cacheWrite=${result.cacheWrite ?? 0}`;
1121
+ return `replay session=${session.entry.sessionID} model=${session.entry.model} api=${session.entry.api} gap=${session.entry.gapId} n=${session.entry.gapReplays}/${session.entry.cap} status=${result.status} latency=${result.latencyMs}ms abort=${abortEvent ?? "first-chunk"}${usage}`;
1122
+ }
1123
+ function numeric(value) {
1124
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
641
1125
  }
642
1126
 
643
1127
  // lib/logger.ts
@@ -681,88 +1165,114 @@ var Logger = class {
681
1165
  };
682
1166
 
683
1167
  // lib/state.ts
684
- import { mkdir as mkdir2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
685
- import { dirname as dirname3 } from "path";
686
- var KeepaliveStore = class {
687
- constructor(config, directory) {
688
- this.config = config;
689
- this.directory = directory;
690
- this.enabled = config.enabled;
691
- }
692
- config;
693
- directory;
694
- sessions = /* @__PURE__ */ new Map();
695
- writeQueue = Promise.resolve();
696
- enabled;
697
- setEnabled(enabled) {
698
- this.enabled = enabled;
699
- }
700
- get(sessionID) {
701
- return this.sessions.get(sessionID);
702
- }
703
- ensure(sessionID) {
704
- const existing = this.sessions.get(sessionID);
705
- if (existing) return existing;
706
- const created = {
707
- sessionID,
708
- eligible: false,
709
- lastResponseAt: 0,
710
- idleSince: 0,
711
- windowEndsAt: 0,
712
- nextPingAt: 0,
713
- pingsSent: 0,
714
- busy: false,
715
- warming: false,
716
- active: false
717
- };
718
- this.sessions.set(sessionID, created);
719
- return created;
720
- }
721
- remove(sessionID) {
722
- this.sessions.delete(sessionID);
723
- }
724
- all() {
725
- return [...this.sessions.values()];
726
- }
727
- /**
728
- * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
729
- * returned promise resolves once this snapshot and every earlier one are written.
730
- */
731
- persist() {
1168
+ import { randomUUID as randomUUID2 } from "crypto";
1169
+ import { readdirSync, readFileSync as readFileSync2, unlinkSync } from "fs";
1170
+ import { mkdir as mkdir2, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
1171
+ import { dirname as dirname3, join as join2 } from "path";
1172
+ var CLEANUP_INTERVAL_MS = 10 * 6e4;
1173
+ var PERSIST_DEBOUNCE_MS = 250;
1174
+ function createStateStore(directory, options = {}) {
1175
+ const now = options.now ?? Date.now;
1176
+ const instanceId = randomUUID2();
1177
+ const filePath = instanceStateFilePath(directory, process.pid, instanceId);
1178
+ const sessions = /* @__PURE__ */ new Map();
1179
+ let totals = {
1180
+ replays: 0,
1181
+ avoidedTokens: 0,
1182
+ replayReadTokens: 0,
1183
+ resumeHits: 0,
1184
+ resumeMisses: 0
1185
+ };
1186
+ let timer;
1187
+ let cleanupTimer;
1188
+ let writeQueue = Promise.resolve();
1189
+ let disposed = false;
1190
+ const flush = () => {
1191
+ if (timer) clearTimeout(timer);
1192
+ timer = void 0;
732
1193
  const snapshot = {
733
- version: 1,
734
- updatedAt: Date.now(),
735
- enabled: this.enabled,
736
- intervalMs: this.config.intervalMs,
737
- windowMs: this.config.windowMs,
738
- sessions: Object.fromEntries(
739
- this.all().map((s) => [
740
- s.sessionID,
741
- {
742
- eligible: s.eligible,
743
- modelLabel: s.modelLabel,
744
- lastResponseAt: s.lastResponseAt,
745
- idleSince: s.idleSince,
746
- windowEndsAt: s.windowEndsAt,
747
- nextPingAt: s.nextPingAt,
748
- intervalMs: this.config.intervalMs,
749
- pingsSent: s.pingsSent,
750
- busy: s.busy,
751
- warming: s.warming,
752
- active: s.active,
753
- lastPing: s.lastPing
754
- }
755
- ])
756
- )
1194
+ version: 2,
1195
+ pid: process.pid,
1196
+ instanceId,
1197
+ updatedAt: now(),
1198
+ sessions: Object.fromEntries(sessions),
1199
+ totals
757
1200
  };
758
- const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
759
- this.writeQueue = this.writeQueue.then(write, write);
760
- return this.writeQueue;
1201
+ const write = () => atomicWrite(filePath, JSON.stringify(snapshot));
1202
+ writeQueue = writeQueue.then(write, write);
1203
+ return writeQueue;
1204
+ };
1205
+ const schedule = () => {
1206
+ if (disposed || timer) return;
1207
+ timer = setTimeout(() => {
1208
+ timer = void 0;
1209
+ void flush();
1210
+ }, PERSIST_DEBOUNCE_MS);
1211
+ };
1212
+ const onExit = () => {
1213
+ try {
1214
+ unlinkSync(filePath);
1215
+ } catch {
1216
+ }
1217
+ };
1218
+ cleanupStateFiles(directory, options.logger);
1219
+ cleanupTimer = setInterval(
1220
+ () => cleanupStateFiles(directory, options.logger),
1221
+ CLEANUP_INTERVAL_MS
1222
+ );
1223
+ process.on("exit", onExit);
1224
+ return {
1225
+ upsert(entry) {
1226
+ if (disposed) return;
1227
+ sessions.set(entry.sessionID, { ...entry });
1228
+ schedule();
1229
+ },
1230
+ remove(sessionID) {
1231
+ if (disposed) return;
1232
+ sessions.delete(sessionID);
1233
+ schedule();
1234
+ },
1235
+ setTotals(value) {
1236
+ if (disposed) return;
1237
+ totals = { ...value };
1238
+ schedule();
1239
+ },
1240
+ async dispose() {
1241
+ if (disposed) return;
1242
+ disposed = true;
1243
+ if (timer) clearTimeout(timer);
1244
+ timer = void 0;
1245
+ if (cleanupTimer) clearInterval(cleanupTimer);
1246
+ cleanupTimer = void 0;
1247
+ process.off("exit", onExit);
1248
+ await flush();
1249
+ await unlink(filePath).catch(() => {
1250
+ });
1251
+ }
1252
+ };
1253
+ }
1254
+ function cleanupStateFiles(directory, logger) {
1255
+ try {
1256
+ const prefix = `state-v2-${stateDirectoryKey(directory)}-`;
1257
+ for (const name of readdirSync(stateDirectoryPath())) {
1258
+ const match = name.match(new RegExp(`^${prefix}(\\d+)-[^/]+\\.json$`));
1259
+ if (!match) continue;
1260
+ const pid = Number(match[1]);
1261
+ try {
1262
+ process.kill(pid, 0);
1263
+ } catch (error) {
1264
+ if (error.code === "ESRCH") {
1265
+ unlinkSync(join2(stateDirectoryPath(), name));
1266
+ logger?.dbg("Removed stale state snapshot", { pid });
1267
+ }
1268
+ }
1269
+ }
1270
+ } catch {
761
1271
  }
762
- };
1272
+ }
763
1273
  async function atomicWrite(path, contents) {
764
1274
  try {
765
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
1275
+ const tmp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
766
1276
  await mkdir2(dirname3(path), { recursive: true });
767
1277
  await writeFile2(tmp, `${contents}
768
1278
  `, "utf8");
@@ -771,54 +1281,27 @@ async function atomicWrite(path, contents) {
771
1281
  }
772
1282
  }
773
1283
 
774
- // lib/system.ts
775
- function keepaliveInstruction(pingToken) {
776
- return [
777
- "<cache_keepalive>",
778
- `A user message consisting of exactly the single token "${pingToken}" is an`,
779
- "automated cache-keepalive ping, not a real request. When you receive it,",
780
- `reply with exactly "${pingToken}" and nothing else. Do not call any tools,`,
781
- "do not explain, and do not take any action.",
782
- "</cache_keepalive>"
783
- ].join("\n");
784
- }
785
-
786
1284
  // index.ts
787
1285
  var server = async (ctx, options) => {
788
- const config = getConfig(options);
789
- const logger = new Logger(config.debug, logFilePath());
790
- if (!config.enabled) {
791
- logger.info("disabled via config");
792
- return {};
793
- }
1286
+ const logger = new Logger(false, logFilePath());
1287
+ const config = getConfig(
1288
+ options,
1289
+ (message) => logger.warn(message)
1290
+ );
1291
+ const activeLogger = new Logger(config.debug, logFilePath());
794
1292
  if (isSecureMode()) configureClientAuth(ctx.client);
795
- const store = new KeepaliveStore(config, ctx.directory);
796
- const engine = new KeepaliveEngine(ctx.client, config, store, logger, ctx.directory);
1293
+ const store = createStateStore(ctx.directory, { logger: activeLogger });
1294
+ const engine = new ReplayWarmer(ctx.client, config, store, activeLogger, ctx.directory);
797
1295
  engine.start();
798
- const version = true ? "0.1.5" : "dev";
1296
+ const version = true ? "0.2.0" : "dev";
799
1297
  logger.info(`v${version} ready`);
800
- const instruction = keepaliveInstruction(config.pingToken);
801
1298
  return {
802
1299
  event: async ({ event }) => {
803
- await engine.onEvent(event);
804
- },
805
- // Keep the system prefix identical between real turns and pings, and make
806
- // the model answer a ping with a single token.
807
- "experimental.chat.system.transform": async (input, output) => {
808
- if (!config.injectSystemInstruction) return;
809
- const model = input.model;
810
- if (!isEligibleModel(config, model?.providerID, model?.id ?? model?.modelID)) return;
811
- output.system.push(instruction);
812
- },
813
- // Backstop: if the model ignores the instruction and tries a tool during a
814
- // ping, block it — the ping turn is going to be reverted anyway.
815
- "tool.execute.before": async (input) => {
816
- if (engine.shouldBlockTools(input.sessionID)) {
817
- throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
818
- }
1300
+ engine.onEvent(event);
819
1301
  },
820
1302
  dispose: async () => {
821
- engine.stop();
1303
+ engine.dispose();
1304
+ await store.dispose();
822
1305
  }
823
1306
  };
824
1307
  };