@vikrant82/opencode-cache-keepalive 0.1.6 → 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 (44) hide show
  1. package/MIGRATION.md +57 -0
  2. package/README.md +75 -51
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1093 -649
  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 -37
  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 -117
  15. package/dist/lib/state.d.ts.map +1 -1
  16. package/dist/lib/tui/commands.d.ts +1 -6
  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.map +1 -1
  21. package/dist/lib/types.d.ts +123 -0
  22. package/dist/lib/types.d.ts.map +1 -0
  23. package/dist/tui.d.ts +3 -0
  24. package/dist/tui.d.ts.map +1 -1
  25. package/lib/config.ts +106 -74
  26. package/lib/control.ts +50 -86
  27. package/lib/paths.ts +12 -14
  28. package/lib/replay-warmer.ts +1200 -0
  29. package/lib/state.ts +171 -181
  30. package/lib/tui/commands.tsx +36 -144
  31. package/lib/tui/footer.tsx +54 -156
  32. package/lib/tui/format.ts +4 -0
  33. package/lib/types.ts +130 -0
  34. package/package.json +3 -2
  35. package/tui.tsx +40 -0
  36. package/dist/lib/keepalive.d.ts +0 -88
  37. package/dist/lib/keepalive.d.ts.map +0 -1
  38. package/dist/lib/model.d.ts +0 -8
  39. package/dist/lib/model.d.ts.map +0 -1
  40. package/dist/lib/system.d.ts +0 -11
  41. package/dist/lib/system.d.ts.map +0 -1
  42. package/lib/keepalive.ts +0 -665
  43. package/lib/model.ts +0 -19
  44. package/lib/system.ts +0 -19
package/dist/index.js CHANGED
@@ -25,66 +25,99 @@ 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
@@ -97,9 +130,14 @@ import { dirname } from "path";
97
130
  import { createHash } from "crypto";
98
131
  import { homedir } from "os";
99
132
  import { join } from "path";
100
- function stateFilePath(directory) {
101
- const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
102
- 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
+ );
103
141
  }
104
142
  function stateDirectoryPath() {
105
143
  return join(
@@ -119,558 +157,971 @@ function logFilePath() {
119
157
  }
120
158
 
121
159
  // lib/control.ts
122
- var MIN_INTERVAL_MS = 6e4;
123
- var MAX_INTERVAL_MS = 24 * 36e5;
124
- function isValidInterval(ms) {
125
- return typeof ms === "number" && Number.isFinite(ms) && ms >= MIN_INTERVAL_MS && ms <= MAX_INTERVAL_MS;
126
- }
127
- function readControl(directory) {
160
+ var VERSION = 2;
161
+ var MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
162
+ function readSessionControls(directory, now = Date.now()) {
128
163
  try {
129
164
  const value = JSON.parse(readFileSync(controlFilePath(directory), "utf8"));
130
- if (value?.version !== 1 || typeof value.updatedAt !== "number") return void 0;
131
- if (value.enabled !== void 0 && typeof value.enabled !== "boolean") return void 0;
132
- return {
133
- version: 1,
134
- updatedAt: value.updatedAt,
135
- ...typeof value.enabled === "boolean" ? { enabled: value.enabled } : {},
136
- ...isValidInterval(value.intervalMs) ? { intervalMs: value.intervalMs } : {}
137
- };
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 };
138
177
  } catch {
139
- return void 0;
178
+ return { version: VERSION, sessions: {} };
140
179
  }
141
180
  }
142
181
  var updateQueue = Promise.resolve();
143
182
 
144
- // lib/model.ts
145
- function isEligibleModel(config, providerID, modelID) {
146
- if (!providerID || !modelID) return false;
147
- const provider = providerID.toLowerCase();
148
- const model = modelID.toLowerCase();
149
- const providerOk = config.providerAllowlist.some((needle) => provider.includes(needle));
150
- const modelOk = config.modelAllowlist.some((needle) => model.includes(needle));
151
- return providerOk && modelOk;
152
- }
153
-
154
- // lib/keepalive.ts
155
- var TICK_MS = 15e3;
156
- var JITTER_MS = 15e3;
157
- var CONTROL_POLL_MS = 1e3;
158
- var STATUS_POLL_MS = 2e3;
159
- var PING_STATUS_GRACE_MS = 5e3;
160
- var COLD_AFTER_INTERVALS = 2;
161
- var MESSAGE_LOOKUP_LIMIT = 50;
162
- var KeepaliveEngine = class {
163
- 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) {
164
188
  this.client = client;
165
189
  this.config = config;
166
- this.store = store;
190
+ this.sink = sink;
167
191
  this.logger = logger;
168
192
  this.directory = directory;
169
- const control = readControl(directory);
170
- this.enabled = control?.enabled ?? config.enabled;
171
- this.intervalOverride = control?.intervalMs;
172
- this.controlUpdatedAt = control?.updatedAt ?? 0;
173
- this.store.setEnabled(this.enabled);
174
- this.store.setIntervalMs(this.intervalMs);
193
+ this.now = now;
175
194
  }
176
195
  client;
177
196
  config;
178
- store;
197
+ sink;
179
198
  logger;
180
199
  directory;
181
- timer;
200
+ now;
201
+ runtime;
182
202
  controlTimer;
183
- statusTimer;
184
- controlUpdatedAt = 0;
185
- reconcilingStatus = false;
186
- enabled;
187
- intervalOverride;
188
- /** Effective ping interval: the runtime override from the control file, else config. */
189
- get intervalMs() {
190
- return this.intervalOverride ?? this.config.intervalMs;
191
- }
203
+ enabled = false;
192
204
  start() {
193
- if (this.timer) return;
194
- this.timer = setInterval(() => void this.tick(), TICK_MS);
195
- this.controlTimer = setInterval(() => this.pollControl(), CONTROL_POLL_MS);
196
- this.statusTimer = setInterval(() => void this.reconcileStatus(), STATUS_POLL_MS);
197
- this.store.persist();
198
- this.logger.info(
199
- `started (${this.enabled ? "enabled" : "disabled"}, interval=${Math.round(this.intervalMs / 1e3)}s, window=${Math.round(this.config.windowMs / 6e4)}m)`
200
- );
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);
201
259
  }
202
- stop() {
203
- if (!this.timer) return;
204
- clearInterval(this.timer);
205
- this.timer = void 0;
260
+ dispose() {
206
261
  if (this.controlTimer) clearInterval(this.controlTimer);
207
262
  this.controlTimer = void 0;
208
- if (this.statusTimer) clearInterval(this.statusTimer);
209
- this.statusTimer = void 0;
210
- }
211
- /**
212
- * True while a ping turn owns the session, so tool calls must be blocked. Becomes
213
- * false as soon as a real user message joins the in-flight ping run: the rest of
214
- * that run is real work and needs its tools.
215
- */
216
- shouldBlockTools(sessionID) {
217
- const s = this.store.get(sessionID);
218
- return !!s?.warming && !s.ping?.interrupted;
219
- }
220
- async onEvent(event) {
221
- const type = event?.type;
222
- if (!type) return;
223
- if (type === "session.deleted") {
224
- const id = event.properties?.sessionID ?? event.properties?.info?.id;
225
- if (id) {
226
- this.store.remove(id);
227
- 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
+ });
228
327
  }
229
328
  return;
230
329
  }
231
- if (type === "message.part.updated") {
232
- const part = event.properties?.part;
233
- const sessionID = part?.sessionID ?? event.properties?.sessionID;
234
- if (!sessionID) return;
235
- const session = this.store.get(sessionID);
236
- 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);
237
335
  return;
238
336
  }
239
- if (type === "message.updated") {
240
- const info = event.properties?.info;
241
- const sessionID = info?.sessionID ?? event.properties?.sessionID;
242
- if (!sessionID) return;
243
- const session = this.store.get(sessionID);
244
- if (session?.warming) {
245
- if (info?.role === "user") this.notePingRunUser(session, info);
246
- 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");
247
362
  }
248
- const eventAt = info?.time?.completed ?? info?.time?.created;
249
- if (session?.lastPingAt && typeof eventAt === "number" && eventAt <= session.lastPingAt)
250
- return;
251
- if (info?.role === "user") {
252
- const s = this.store.ensure(sessionID);
253
- if (!isNewUserMessage(s, info)) return;
254
- noteUserMessage(s, info);
255
- s.busy = true;
256
- s.active = false;
257
- 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());
258
371
  return;
259
372
  }
260
- if (info?.role === "assistant" && typeof info?.time?.completed === "number") {
261
- const s = this.store.ensure(sessionID);
262
- s.lastResponseAt = info.time.completed;
263
- 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");
264
377
  }
265
378
  return;
266
379
  }
267
- if (type === "session.status") {
268
- const sessionID = event.properties?.sessionID;
269
- const status = event.properties?.status?.type;
270
- if (!sessionID || !status) return;
271
- const current = this.store.get(sessionID);
272
- if (current?.warming || status !== "idle" && current?.lastPingAt && Date.now() - current.lastPingAt < PING_STATUS_GRACE_MS)
273
- return;
274
- if (status === "idle") {
275
- await this.armWindow(sessionID);
276
- 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;
277
418
  }
278
- const s = this.store.ensure(sessionID);
279
- s.busy = true;
280
- s.active = false;
281
- this.store.persist();
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);
437
+ }
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");
282
471
  return;
283
472
  }
284
- if (type === "session.idle") {
285
- const sessionID = event.properties?.sessionID;
286
- if (!sessionID) return;
287
- if (this.store.get(sessionID)?.warming) return;
288
- await this.armWindow(sessionID);
289
- }
290
- }
291
- /**
292
- * Track a new user message observed while a ping is in flight. Event order alone
293
- * cannot tell the ping's own message from a real prompt racing it, so messages are
294
- * held until their text part arrives (see `notePingRunPart`). Any message seen
295
- * before the ping is sent, or a second new message, is necessarily a real turn.
296
- */
297
- notePingRunUser(s, info) {
298
- const ping = s.ping;
299
- if (!ping || typeof info?.id !== "string") return;
300
- const created = info?.time?.created;
301
- if (typeof created === "number" && created < ping.startedAt) return;
302
- if (info.id === ping.messageID || ping.pending.has(info.id)) return;
303
- if (!ping.sent || ping.messageID) {
304
- 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");
305
477
  return;
306
478
  }
307
- ping.pending.set(info.id, info);
308
- if (ping.pending.size > 1) this.interruptPing(s);
309
- }
310
- /**
311
- * Classify a pending user message by its first text part: the ping token marks
312
- * the ping's own message; any other text is a real turn joining the ping run.
313
- * opencode publishes a user message's parts right after the message itself, before
314
- * the run reaches any tool call.
315
- */
316
- notePingRunPart(s, part) {
317
- const ping = s.ping;
318
- if (!ping || part?.type !== "text" || part?.synthetic) return;
319
- const id = part?.messageID;
320
- if (typeof id !== "string" || !ping.pending.has(id)) return;
321
- const info = ping.pending.get(id);
322
- ping.pending.delete(id);
323
- if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
324
- 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");
325
482
  return;
326
483
  }
327
- this.interruptPing(s, info);
328
- }
329
- /** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
330
- interruptPing(s, info) {
331
- const ping = s.ping;
332
- if (!ping) return;
333
- if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`);
334
- ping.interrupted = true;
335
- if (info) noteUserMessage(s, info);
336
- s.busy = true;
337
- s.active = false;
338
- this.store.persist();
339
- }
340
- async armWindow(sessionID) {
341
- const s = this.store.ensure(sessionID);
342
- if (s.active && !s.busy) return;
343
- if (s.arming) return;
344
- 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;
345
489
  try {
346
- s.busy = false;
347
- await this.resolveSession(s);
348
- if (s.busy || !this.enabled || !s.eligible) {
349
- s.active = false;
350
- this.store.persist();
351
- 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
+ };
352
514
  }
353
- const now = Date.now();
354
- if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
355
- s.active = false;
356
- this.store.persist();
357
- this.logger.dbg(`not armed ${short(sessionID)} \u2014 no recent real response`);
358
- 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);
359
526
  }
360
- this.openWindow(s, now);
361
- this.store.persist();
362
- this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`);
363
- } finally {
364
- s.arming = false;
365
- }
366
- }
367
- /** Start a warm window anchored on the last real response and the last cache touch. */
368
- openWindow(s, now) {
369
- s.idleSince = now;
370
- s.windowEndsAt = s.lastResponseAt + this.config.windowMs;
371
- s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs);
372
- s.pingsSent = 0;
373
- s.lastPing = void 0;
374
- s.active = true;
375
- }
376
- /** False once the cache has gone too long without a request to still be trusted warm. */
377
- cacheMayBeWarm(s, now) {
378
- const touch = lastCacheTouch(s);
379
- return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs;
380
- }
381
- async tick() {
382
- if (!this.enabled) return;
383
- const now = Date.now();
384
- for (const s of this.store.all()) {
385
- if (!s.eligible || !s.active || s.busy || s.warming) continue;
386
- if (now >= s.windowEndsAt) {
387
- s.active = false;
388
- this.store.persist();
389
- this.logger.dbg(
390
- `window closed ${short(s.sessionID)} after ${s.pingsSent} ping(s) \u2014 cache may cool`
391
- );
392
- 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);
393
537
  }
394
- if (now < s.nextPingAt) continue;
395
- if (!this.cacheMayBeWarm(s, now)) {
396
- s.active = false;
397
- this.store.persist();
398
- this.logger.dbg(
399
- `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"
400
557
  );
401
- continue;
558
+ if (session) {
559
+ this.invalidateTarget(runtime, session, sentAt);
402
560
  }
403
- void this.firePing(s.sessionID);
561
+ return;
404
562
  }
405
- }
406
- async firePing(sessionID) {
407
- const s = this.store.get(sessionID);
408
- if (!s || s.warming || s.busy || !s.active) return;
409
- const ping = {
410
- startedAt: Date.now(),
411
- sent: false,
412
- pending: /* @__PURE__ */ new Map(),
413
- 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
414
642
  };
415
- s.warming = true;
416
- s.ping = ping;
417
- 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;
418
732
  try {
419
- if (!await this.isIdleOnServer(sessionID)) {
420
- s.busy = true;
421
- s.active = false;
422
- 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);
423
753
  return;
424
754
  }
425
- const request = s.request ?? await this.lookupRequest(s);
426
- if (!request) {
427
- s.active = false;
428
- 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);
429
759
  return;
430
760
  }
431
- if (ping.interrupted || s.busy || !s.active || !this.enabled) return;
432
- ping.sent = true;
433
- s.pingsSent += 1;
434
- this.store.persist();
435
- const res = await this.client.session.prompt({
436
- path: { id: sessionID },
437
- body: {
438
- parts: [{ type: "text", text: this.config.pingToken }],
439
- ...request.agent ? { agent: request.agent } : {},
440
- model: { providerID: request.providerID, modelID: request.modelID },
441
- ...request.variant ? { variant: request.variant } : {}
442
- }
443
- });
444
- const info = res?.data?.info ?? res?.info;
445
- if (ping.interrupted) {
446
- const completed = info?.time?.completed;
447
- if (typeof completed === "number") s.lastResponseAt = completed;
448
- this.logger.dbg(
449
- `ping ${short(sessionID)} absorbed a real turn \u2014 kept, not reverted`
450
- );
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");
451
780
  return;
452
781
  }
453
- const cache = info?.tokens?.cache ?? {};
454
- const record = {
455
- at: Date.now(),
456
- hit: Number(cache.read ?? 0) > 0,
457
- input: Number(info?.tokens?.input ?? 0),
458
- cacheRead: Number(cache.read ?? 0),
459
- cacheWrite: Number(cache.write ?? 0),
460
- output: Number(info?.tokens?.output ?? 0)
461
- };
462
- s.lastPing = record;
463
- this.logger.dbg(
464
- `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
+ )
465
797
  );
466
- if (this.config.revertPing) await this.revertPing(sessionID, ping);
467
- } catch (err) {
468
- this.logger.warn(`ping ${short(sessionID)} failed`, errText(err));
798
+ this.retryOrLapse(id, session, sentAt, timeoutHit ? "timeout" : "network");
469
799
  } finally {
470
- s.warming = false;
471
- s.ping = void 0;
472
- const now = Date.now();
473
- if (ping.sent) {
474
- s.lastPingAt = now;
475
- const next = s.nextPingAt + jitter(this.intervalMs);
476
- s.nextPingAt = next > now ? next : now + jitter(this.intervalMs);
477
- }
478
- this.store.persist();
479
- if (ping.sent && ping.interrupted) {
480
- s.busy = false;
481
- s.active = false;
482
- void this.armWindow(sessionID);
483
- }
800
+ if (timeout) clearTimeout(timeout);
801
+ controller.abort();
802
+ if (session.controller === controller) session.controller = void 0;
484
803
  }
485
804
  }
486
- /** True only when the server positively reports the session idle. */
487
- async isIdleOnServer(sessionID) {
488
- try {
489
- const res = await this.client.session.status();
490
- const statuses = res?.data ?? res ?? {};
491
- return (statuses?.[sessionID]?.type ?? "idle") === "idle";
492
- } catch (err) {
493
- this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err));
494
- return false;
495
- }
496
- }
497
- /** Recover the last real turn's request settings when no user event was observed. */
498
- async lookupRequest(s) {
499
- const messages = await this.recentMessages(s.sessionID);
500
- if (!messages) return void 0;
501
- for (let i = messages.length - 1; i >= 0; i--) {
502
- const m = messages[i];
503
- if (m?.info?.role !== "user" || this.isPingMessage(m)) continue;
504
- const request = requestFrom(m.info);
505
- if (request) {
506
- s.request = request;
507
- return request;
508
- }
509
- }
510
- 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}`);
511
810
  }
512
- async recentMessages(sessionID) {
513
- try {
514
- const res = await this.client.session.messages({
515
- path: { id: sessionID },
516
- query: { limit: MESSAGE_LOOKUP_LIMIT }
517
- });
518
- const messages = res?.data ?? res;
519
- return Array.isArray(messages) ? messages : void 0;
520
- } catch (err) {
521
- this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err));
522
- 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
+ );
523
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);
524
833
  }
525
- isPingMessage(m) {
526
- const text = (m?.parts ?? []).filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
527
- return text === this.config.pingToken;
528
- }
529
- /**
530
- * Remove the `~` user + `~` assistant turn so it never enters real context.
531
- * Revert drops the target and everything after it (and rolls back file
532
- * snapshots), so it is skipped unless only the ping's own replies follow.
533
- */
534
- async revertPing(sessionID, ping) {
535
- const messages = await this.recentMessages(sessionID);
536
- if (!messages) return;
537
- let index = -1;
538
- for (let i = 0; i < messages.length; i++) {
539
- const m = messages[i];
540
- if (m?.info?.role !== "user") continue;
541
- if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
542
- index = i;
543
- }
544
- if (index < 0) {
545
- this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`);
546
- return;
547
- }
548
- const target = messages[index].info.id;
549
- const foreign = messages.slice(index + 1).some((m) => m?.info?.role !== "assistant" || m?.info?.parentID !== target);
550
- if (foreign) {
551
- this.logger.warn(`ping ${short(sessionID)} not reverted \u2014 other messages followed it`);
552
- 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`);
553
875
  }
554
- try {
555
- await this.client.session.revert({
556
- path: { id: sessionID },
557
- body: { messageID: target }
558
- });
559
- } catch (err) {
560
- 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
+ }
561
901
  }
562
902
  }
563
- isRecentPing(m, ping) {
564
- 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;
565
936
  }
566
- 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;
567
953
  try {
568
- const res = await this.client.session.get({ path: { id: s.sessionID } });
569
- const info = res?.data ?? res;
570
- if (info?.parentID && !this.config.includeChildSessions) {
571
- s.eligible = false;
572
- s.modelLabel = info?.model?.id ?? "child";
573
- return;
574
- }
575
- s.modelLabel = info?.model?.id ?? "unknown";
576
- s.eligible = isEligibleModel(this.config, info?.model?.providerID, info?.model?.id);
577
- } catch (err) {
578
- s.eligible = false;
579
- this.logger.dbg(`resolve ${short(s.sessionID)} failed`, errText(err));
580
- }
581
- }
582
- pollControl() {
583
- const control = readControl(this.directory);
584
- if (!control || control.updatedAt <= this.controlUpdatedAt) return;
585
- this.controlUpdatedAt = control.updatedAt;
586
- const now = Date.now();
587
- let changed = false;
588
- if (control.intervalMs !== this.intervalOverride) {
589
- this.intervalOverride = control.intervalMs;
590
- this.store.setIntervalMs(this.intervalMs);
591
- for (const session of this.store.all()) {
592
- if (session.active && !session.warming)
593
- session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs);
594
- }
595
- this.logger.info(
596
- `runtime interval ${Math.round(this.intervalMs / 1e3)}s` + (control.intervalMs === void 0 ? " (config)" : "")
597
- );
598
- changed = true;
599
- }
600
- const enabled = control.enabled ?? this.config.enabled;
601
- if (enabled !== this.enabled) {
602
- this.enabled = enabled;
603
- this.store.setEnabled(enabled);
604
- for (const session of this.store.all()) {
605
- if (!enabled) {
606
- session.active = false;
607
- continue;
608
- }
609
- if (!session.eligible || session.busy || session.warming) continue;
610
- if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue;
611
- this.openWindow(session, now);
612
- }
613
- this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`);
614
- changed = 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;
615
957
  }
616
- if (changed) this.store.persist();
617
- }
618
- /** Recover when a session.status idle event is missed by reconciling with the API. */
619
- async reconcileStatus() {
620
- if (this.reconcilingStatus) return;
621
- const busy = this.store.all().filter((session) => session.busy && !session.warming);
622
- if (busy.length === 0) return;
623
- this.reconcilingStatus = true;
958
+ let parsed;
624
959
  try {
625
- const res = await this.client.session.status();
626
- const statuses = res?.data ?? res ?? {};
627
- for (const session of busy) {
628
- const status = statuses?.[session.sessionID]?.type ?? "idle";
629
- if (status === "idle") await this.armWindow(session.sessionID);
630
- }
631
- } catch (err) {
632
- this.logger.dbg("status reconciliation failed", errText(err));
633
- } finally {
634
- 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;
635
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;
636
989
  }
637
- };
638
- function lastCacheTouch(s) {
639
- 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);
640
992
  }
641
- function isNewUserMessage(s, info) {
642
- if (typeof info?.id === "string" && info.id === s.lastUserID) return false;
643
- const created = info?.time?.created;
644
- return typeof created !== "number" || created >= (s.lastUserAt ?? 0);
993
+ function bodyBytes(body) {
994
+ return new TextEncoder().encode(body).byteLength;
645
995
  }
646
- function noteUserMessage(s, info) {
647
- if (typeof info?.id === "string") s.lastUserID = info.id;
648
- const created = info?.time?.created;
649
- if (typeof created === "number") s.lastUserAt = created;
650
- const request = requestFrom(info);
651
- 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
+ }
652
1007
  }
653
- function requestFrom(info) {
654
- const model = info?.model;
655
- const providerID = model?.providerID;
656
- const modelID = model?.modelID ?? model?.id;
657
- if (typeof providerID !== "string" || typeof modelID !== "string") return void 0;
658
- const variant = model?.variant ?? info?.variant;
659
- return {
660
- agent: typeof info?.agent === "string" ? info.agent : void 0,
661
- providerID,
662
- modelID,
663
- variant: typeof variant === "string" && variant !== "default" ? variant : void 0
664
- };
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
+ }
665
1050
  }
666
- function short(sessionID) {
667
- 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.");
668
1054
  }
669
- function jitter(base) {
670
- 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
+ }
1062
+ }
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" };
671
1118
  }
672
- function errText(err) {
673
- return err instanceof Error ? err.message : String(err);
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;
674
1125
  }
675
1126
 
676
1127
  // lib/logger.ts
@@ -714,94 +1165,114 @@ var Logger = class {
714
1165
  };
715
1166
 
716
1167
  // lib/state.ts
717
- import { mkdir as mkdir2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
718
- import { dirname as dirname3 } from "path";
719
- var KeepaliveStore = class {
720
- constructor(config, directory) {
721
- this.config = config;
722
- this.directory = directory;
723
- this.enabled = config.enabled;
724
- this.intervalMs = config.intervalMs;
725
- }
726
- config;
727
- directory;
728
- sessions = /* @__PURE__ */ new Map();
729
- writeQueue = Promise.resolve();
730
- enabled;
731
- intervalMs;
732
- setEnabled(enabled) {
733
- this.enabled = enabled;
734
- }
735
- /** Effective ping interval (runtime override or config) reported to the TUI. */
736
- setIntervalMs(intervalMs) {
737
- this.intervalMs = intervalMs;
738
- }
739
- get(sessionID) {
740
- return this.sessions.get(sessionID);
741
- }
742
- ensure(sessionID) {
743
- const existing = this.sessions.get(sessionID);
744
- if (existing) return existing;
745
- const created = {
746
- sessionID,
747
- eligible: false,
748
- lastResponseAt: 0,
749
- idleSince: 0,
750
- windowEndsAt: 0,
751
- nextPingAt: 0,
752
- pingsSent: 0,
753
- busy: false,
754
- warming: false,
755
- active: false
756
- };
757
- this.sessions.set(sessionID, created);
758
- return created;
759
- }
760
- remove(sessionID) {
761
- this.sessions.delete(sessionID);
762
- }
763
- all() {
764
- return [...this.sessions.values()];
765
- }
766
- /**
767
- * Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
768
- * returned promise resolves once this snapshot and every earlier one are written.
769
- */
770
- 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;
771
1193
  const snapshot = {
772
- version: 1,
773
- updatedAt: Date.now(),
774
- enabled: this.enabled,
775
- intervalMs: this.intervalMs,
776
- windowMs: this.config.windowMs,
777
- sessions: Object.fromEntries(
778
- this.all().map((s) => [
779
- s.sessionID,
780
- {
781
- eligible: s.eligible,
782
- modelLabel: s.modelLabel,
783
- lastResponseAt: s.lastResponseAt,
784
- idleSince: s.idleSince,
785
- windowEndsAt: s.windowEndsAt,
786
- nextPingAt: s.nextPingAt,
787
- intervalMs: this.intervalMs,
788
- pingsSent: s.pingsSent,
789
- busy: s.busy,
790
- warming: s.warming,
791
- active: s.active,
792
- lastPing: s.lastPing
793
- }
794
- ])
795
- )
1194
+ version: 2,
1195
+ pid: process.pid,
1196
+ instanceId,
1197
+ updatedAt: now(),
1198
+ sessions: Object.fromEntries(sessions),
1199
+ totals
796
1200
  };
797
- const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot));
798
- this.writeQueue = this.writeQueue.then(write, write);
799
- 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 {
800
1271
  }
801
- };
1272
+ }
802
1273
  async function atomicWrite(path, contents) {
803
1274
  try {
804
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
1275
+ const tmp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
805
1276
  await mkdir2(dirname3(path), { recursive: true });
806
1277
  await writeFile2(tmp, `${contents}
807
1278
  `, "utf8");
@@ -810,54 +1281,27 @@ async function atomicWrite(path, contents) {
810
1281
  }
811
1282
  }
812
1283
 
813
- // lib/system.ts
814
- function keepaliveInstruction(pingToken) {
815
- return [
816
- "<cache_keepalive>",
817
- `A user message consisting of exactly the single token "${pingToken}" is an`,
818
- "automated cache-keepalive ping, not a real request. When you receive it,",
819
- `reply with exactly "${pingToken}" and nothing else. Do not call any tools,`,
820
- "do not explain, and do not take any action.",
821
- "</cache_keepalive>"
822
- ].join("\n");
823
- }
824
-
825
1284
  // index.ts
826
1285
  var server = async (ctx, options) => {
827
- const config = getConfig(options);
828
- const logger = new Logger(config.debug, logFilePath());
829
- if (!config.enabled) {
830
- logger.info("disabled via config");
831
- return {};
832
- }
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());
833
1292
  if (isSecureMode()) configureClientAuth(ctx.client);
834
- const store = new KeepaliveStore(config, ctx.directory);
835
- 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);
836
1295
  engine.start();
837
- const version = true ? "0.1.6" : "dev";
1296
+ const version = true ? "0.2.0" : "dev";
838
1297
  logger.info(`v${version} ready`);
839
- const instruction = keepaliveInstruction(config.pingToken);
840
1298
  return {
841
1299
  event: async ({ event }) => {
842
- await engine.onEvent(event);
843
- },
844
- // Keep the system prefix identical between real turns and pings, and make
845
- // the model answer a ping with a single token.
846
- "experimental.chat.system.transform": async (input, output) => {
847
- if (!config.injectSystemInstruction) return;
848
- const model = input.model;
849
- if (!isEligibleModel(config, model?.providerID, model?.id ?? model?.modelID)) return;
850
- output.system.push(instruction);
851
- },
852
- // Backstop: if the model ignores the instruction and tries a tool during a
853
- // ping, block it — the ping turn is going to be reverted anyway.
854
- "tool.execute.before": async (input) => {
855
- if (engine.shouldBlockTools(input.sessionID)) {
856
- throw new Error("cache-keepalive: tools are disabled during a keepalive ping");
857
- }
1300
+ engine.onEvent(event);
858
1301
  },
859
1302
  dispose: async () => {
860
- engine.stop();
1303
+ engine.dispose();
1304
+ await store.dispose();
861
1305
  }
862
1306
  };
863
1307
  };