@cesarus777/opencode-cgp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1507 @@
1
+ // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode/plugin/rpc";
4
+ var count = {
5
+ type: "integer",
6
+ minimum: 0,
7
+ maximum: Number.MAX_SAFE_INTEGER
8
+ };
9
+ var text = { type: "string", minLength: 1, maxLength: 512 };
10
+ var enumeration = (...values) => ({ type: "string", enum: values });
11
+ var object = (properties, required = Object.keys(properties)) => ({
12
+ type: "object",
13
+ properties,
14
+ required,
15
+ additionalProperties: false
16
+ });
17
+ var array = (items, maxItems = 128) => ({
18
+ type: "array",
19
+ items,
20
+ maxItems
21
+ });
22
+ var measurement = object({
23
+ tokens: count,
24
+ at: count,
25
+ model: text,
26
+ requestId: text,
27
+ provenance: enumeration("provider-normalized", "serialized-estimate"),
28
+ confidence: enumeration("reported", "approximate"),
29
+ scope: enumeration("completed-request", "prepared-request")
30
+ }, ["tokens", "at", "model", "provenance", "confidence", "scope"]);
31
+ var row = object({
32
+ sessionId: text,
33
+ revision: count,
34
+ observedAt: count,
35
+ parentId: text,
36
+ agent: text,
37
+ model: text,
38
+ measurement,
39
+ limits: object({ context: count, input: count, output: count }, [
40
+ "context",
41
+ "output"
42
+ ]),
43
+ capacityTokens: count,
44
+ outputReserveTokens: count,
45
+ peakTokens: count,
46
+ policy: object({
47
+ id: text,
48
+ configured: object({
49
+ type: enumeration("tokens", "ratio", "remaining"),
50
+ value: { type: "number", minimum: 0 }
51
+ }),
52
+ triggerTokens: { type: "number" },
53
+ comparison: enumeration(">=", ">"),
54
+ reason: enumeration("missing-usage", "model-changed", "invalid-usage", "stale-usage", "missing-model-budget", "incompatible-model-budget", "threshold-crossed", "below-threshold"),
55
+ crossed: { type: "boolean" }
56
+ }, ["id", "configured", "comparison", "reason", "crossed"]),
57
+ compaction: object({
58
+ phase: enumeration("idle", "requesting", "accepted", "running", "awaiting-measurement", "blocked", "uncertain"),
59
+ count,
60
+ armed: { type: "boolean" },
61
+ blockedReason: enumeration("session-removed", "compaction-failed", "reclaim-unverifiable", "insufficient-reclaim", "completion-event-timeout", "admission-outcome-unknown", "restart-needs-lifecycle-evidence"),
62
+ lastAttempt: count,
63
+ lastCompleted: count,
64
+ cooldownUntil: count
65
+ }, ["phase", "armed"]),
66
+ cumulative: object({
67
+ input: count,
68
+ output: count,
69
+ reasoning: count,
70
+ cache: object({ read: count, write: count })
71
+ }),
72
+ cumulativeScope: enumeration("session", "unknown"),
73
+ lastObservedActive: { type: "boolean" }
74
+ }, [
75
+ "sessionId",
76
+ "revision",
77
+ "observedAt",
78
+ "policy",
79
+ "compaction",
80
+ "cumulativeScope",
81
+ "lastObservedActive"
82
+ ]);
83
+ var batchFields = {
84
+ schemaVersion: { const: 1 },
85
+ epoch: text,
86
+ sequence: count,
87
+ generatedAt: count,
88
+ health: object({
89
+ enabled: { type: "boolean" },
90
+ mode: enumeration("observe", "enforce"),
91
+ enforcement: enumeration("observing", "available", "suspended", "disabled"),
92
+ reasons: array(enumeration("event-stream-gap", "persistence-unavailable", "capacity-exceeded", "model-metadata-unavailable"), 8),
93
+ staleMs: count,
94
+ maxSessions: count
95
+ }),
96
+ upserts: array(row),
97
+ removed: array(object({
98
+ sessionId: text,
99
+ revision: count,
100
+ reason: enumeration("deleted", "evicted")
101
+ }))
102
+ };
103
+ var readSchema = object({
104
+ schemaVersion: { const: 1 },
105
+ sessionIds: { ...array(text), uniqueItems: true }
106
+ });
107
+ var batchSchema = object(batchFields);
108
+ var resultSchema = object({
109
+ ...batchFields,
110
+ unavailable: array(object({ sessionId: text, reason: enumeration("not-monitored") }))
111
+ });
112
+ var monitorRpc = Rpc.define({
113
+ id: "context-guard.monitor.v1",
114
+ methods: { read: { input: readSchema, output: resultSchema } },
115
+ events: { changed: { schema: batchSchema } }
116
+ });
117
+ function record(x) {
118
+ return typeof x === "object" && x !== null && !Array.isArray(x);
119
+ }
120
+ function matches(value, schema) {
121
+ if (!record(schema))
122
+ return false;
123
+ if ("const" in schema && value !== schema.const)
124
+ return false;
125
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value))
126
+ return false;
127
+ if (schema.type === "number" || schema.type === "integer")
128
+ return typeof value === "number" && Number.isFinite(value) && (schema.type !== "integer" || Number.isSafeInteger(value)) && (typeof schema.minimum !== "number" || value >= schema.minimum) && (typeof schema.maximum !== "number" || value <= schema.maximum);
129
+ if (schema.type === "string")
130
+ return typeof value === "string" && value.length > 0 && value.length <= 512;
131
+ if (schema.type === "boolean")
132
+ return typeof value === "boolean";
133
+ if (schema.type === "array")
134
+ return Array.isArray(value) && value.length <= Number(schema.maxItems) && (!schema.uniqueItems || new Set(value).size === value.length) && value.every((v) => matches(v, schema.items));
135
+ if (schema.type === "object") {
136
+ if (!record(value) || !record(schema.properties) || !Array.isArray(schema.required))
137
+ return false;
138
+ const properties = schema.properties;
139
+ return schema.required.every((k) => typeof k === "string" && (k in value)) && Object.keys(value).every((k) => (k in properties) && matches(value[k], properties[k]));
140
+ }
141
+ return "const" in schema;
142
+ }
143
+ function valid(value, schema) {
144
+ try {
145
+ return JSON.stringify(value).length <= 524288 && matches(value, schema);
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+ function isRead(value) {
151
+ return valid(value, readSchema);
152
+ }
153
+ function isBatch(value) {
154
+ return valid(value, batchSchema) && uniqueRows(value);
155
+ }
156
+ function isResult(value) {
157
+ return valid(value, resultSchema) && uniqueRows(value);
158
+ }
159
+ function uniqueRows(value) {
160
+ if (!record(value) || !Array.isArray(value.upserts) || !Array.isArray(value.removed))
161
+ return false;
162
+ const ids = [...value.upserts, ...value.removed].map((v) => record(v) ? v.sessionId : undefined);
163
+ return ids.length <= 128 && new Set(ids).size === ids.length;
164
+ }
165
+
166
+ // src/index.ts
167
+ import { Plugin } from "@opencode/plugin";
168
+
169
+ // src/policy.ts
170
+ function glob(pattern, text2) {
171
+ let p = 0, t = 0, star = -1, retry = 0;
172
+ while (t < text2.length) {
173
+ if (pattern[p] === "?" || pattern[p] === text2[t]) {
174
+ p++;
175
+ t++;
176
+ } else if (pattern[p] === "*") {
177
+ star = p++;
178
+ retry = t;
179
+ } else if (star >= 0) {
180
+ p = star + 1;
181
+ t = ++retry;
182
+ } else
183
+ return false;
184
+ }
185
+ while (pattern[p] === "*")
186
+ p++;
187
+ return p === pattern.length;
188
+ }
189
+
190
+ class PolicyResolver {
191
+ config;
192
+ constructor(config) {
193
+ this.config = config;
194
+ }
195
+ resolve(identity) {
196
+ let result = {
197
+ threshold: this.config.defaults.threshold,
198
+ policy: "defaults"
199
+ };
200
+ this.config.policies.forEach((p, i) => {
201
+ const m = p.match;
202
+ if (m.role && m.role !== (identity.parentId ? "subagent" : "main"))
203
+ return;
204
+ if (m.agent && (!identity.agent || !glob(m.agent, identity.agent)))
205
+ return;
206
+ if (m.model && (!identity.model || !glob(m.model, identity.model)))
207
+ return;
208
+ result = { threshold: p.threshold, policy: `policies[${i}]` };
209
+ });
210
+ return result;
211
+ }
212
+ }
213
+ function usableInput(limits, reserve) {
214
+ if (!limits)
215
+ return;
216
+ const valid2 = (n) => n !== undefined && Number.isFinite(n) && n > 0;
217
+ const output = reserve ?? (valid2(limits.output) ? limits.output : undefined);
218
+ const contextBudget = valid2(limits.context) && output !== undefined ? Math.max(0, limits.context - output) : undefined;
219
+ if (valid2(limits.input))
220
+ return contextBudget === undefined ? limits.input : Math.min(limits.input, contextBudget);
221
+ return contextBudget;
222
+ }
223
+ function evaluateThreshold(threshold, measurement2, limits, now, staleMs, model, policy, reserve) {
224
+ const budget = usableInput(limits, reserve);
225
+ const base = {
226
+ crossed: false,
227
+ reason: "missing-usage",
228
+ policy,
229
+ ...budget === undefined ? {} : { budget }
230
+ };
231
+ const trigger = threshold.type === "tokens" ? threshold.value : budget === undefined ? undefined : threshold.type === "ratio" ? Math.ceil(budget * threshold.value) : budget - threshold.value;
232
+ if (trigger !== undefined)
233
+ base.threshold = trigger;
234
+ if (!measurement2)
235
+ return base;
236
+ if (model !== measurement2.model)
237
+ return { ...base, reason: "model-changed" };
238
+ if (!Number.isFinite(measurement2.tokens) || measurement2.tokens < 0)
239
+ return { ...base, reason: "invalid-usage" };
240
+ if (now - measurement2.at > staleMs || measurement2.at > now)
241
+ return { ...base, reason: "stale-usage" };
242
+ if (budget !== undefined && budget > 0)
243
+ base.percentage = measurement2.tokens / budget * 100;
244
+ if (trigger === undefined)
245
+ return { ...base, reason: "missing-model-budget" };
246
+ if (trigger <= 0)
247
+ return { ...base, reason: "incompatible-model-budget" };
248
+ const crossed = threshold.type === "remaining" ? measurement2.tokens > trigger : measurement2.tokens >= trigger;
249
+ return {
250
+ ...base,
251
+ crossed,
252
+ reason: crossed ? "threshold-crossed" : "below-threshold"
253
+ };
254
+ }
255
+
256
+ // src/snapshot.ts
257
+ function project(s, config, now, revision = 0) {
258
+ const p = new PolicyResolver(config).resolve(s);
259
+ const d = evaluateThreshold(p.threshold, s.usage.current, s.limits, now, config.safety.staleMs, s.model, p.policy, config.safety.outputReserveTokens);
260
+ const c = s.compaction;
261
+ return freezeSnapshot({
262
+ sessionId: s.sessionId,
263
+ revision,
264
+ observedAt: s.lastSeen,
265
+ ...s.parentId ? { parentId: s.parentId } : {},
266
+ ...s.agent ? { agent: s.agent } : {},
267
+ ...s.model ? { model: s.model } : {},
268
+ ...s.usage.current ? {
269
+ measurement: {
270
+ tokens: s.usage.current.tokens,
271
+ at: s.usage.current.at,
272
+ model: s.usage.current.model,
273
+ provenance: s.usage.current.provenance,
274
+ confidence: s.usage.current.confidence,
275
+ scope: s.usage.current.scope,
276
+ ...s.usage.current.requestId ? { requestId: s.usage.current.requestId } : {}
277
+ }
278
+ } : {},
279
+ ...s.limits ? {
280
+ limits: {
281
+ context: s.limits.context,
282
+ output: s.limits.output,
283
+ ...s.limits.input === undefined ? {} : { input: s.limits.input }
284
+ }
285
+ } : {},
286
+ ...d.budget === undefined ? {} : { capacityTokens: d.budget },
287
+ ...config.safety.outputReserveTokens === undefined && !s.limits ? {} : {
288
+ outputReserveTokens: config.safety.outputReserveTokens ?? s.limits?.output ?? 0
289
+ },
290
+ peakTokens: s.usage.peak,
291
+ policy: {
292
+ id: p.policy,
293
+ configured: { type: p.threshold.type, value: p.threshold.value },
294
+ ...d.threshold === undefined ? {} : { triggerTokens: d.threshold },
295
+ comparison: p.threshold.type === "remaining" ? ">" : ">=",
296
+ reason: d.reason,
297
+ crossed: d.crossed
298
+ },
299
+ compaction: {
300
+ phase: c.phase,
301
+ count: c.count,
302
+ armed: c.armed,
303
+ ...c.blockedReason ? { blockedReason: c.blockedReason } : {},
304
+ ...c.lastAttempt === undefined ? {} : {
305
+ lastAttempt: c.lastAttempt,
306
+ cooldownUntil: c.lastAttempt + config.safety.cooldownMs
307
+ },
308
+ ...c.lastCompleted === undefined ? {} : { lastCompleted: c.lastCompleted }
309
+ },
310
+ ...s.usage.cumulative ? {
311
+ cumulative: {
312
+ input: s.usage.cumulative.input,
313
+ output: s.usage.cumulative.output,
314
+ reasoning: s.usage.cumulative.reasoning,
315
+ cache: {
316
+ read: s.usage.cumulative.cache.read,
317
+ write: s.usage.cumulative.cache.write
318
+ }
319
+ }
320
+ } : {},
321
+ cumulativeScope: s.usage.cumulativeScope,
322
+ lastObservedActive: s.active
323
+ });
324
+ }
325
+
326
+ class SnapshotStore {
327
+ config;
328
+ sessions;
329
+ now;
330
+ epoch;
331
+ sequence = 0;
332
+ revision = 0;
333
+ rows = new Map;
334
+ deleted = new Set;
335
+ dirty = new Set;
336
+ listeners = new Set;
337
+ healthKey = "";
338
+ reasons = new Set;
339
+ constructor(config, sessions, now = Date.now, epoch = crypto.randomUUID()) {
340
+ this.config = config;
341
+ this.sessions = sessions;
342
+ this.now = now;
343
+ this.epoch = epoch;
344
+ }
345
+ health() {
346
+ return {
347
+ enabled: this.config.enabled,
348
+ mode: this.config.mode,
349
+ enforcement: !this.config.enabled ? "disabled" : this.reasons.has("event-stream-gap") || this.reasons.has("persistence-unavailable") ? "suspended" : this.config.mode === "observe" ? "observing" : "available",
350
+ reasons: [...this.reasons].sort(),
351
+ staleMs: this.config.safety.staleMs,
352
+ maxSessions: this.config.safety.maxSessions
353
+ };
354
+ }
355
+ degrade(reason) {
356
+ this.reasons.add(reason);
357
+ }
358
+ recover(reason) {
359
+ this.reasons.delete(reason);
360
+ }
361
+ mark(id) {
362
+ if (this.dirty.size < this.config.safety.maxSessions)
363
+ this.dirty.add(id);
364
+ }
365
+ markAll() {
366
+ for (const id of this.sessions().keys())
367
+ this.mark(id);
368
+ }
369
+ remove(id) {
370
+ if (this.rows.has(id))
371
+ this.deleted.add(id);
372
+ }
373
+ listen(fn) {
374
+ this.listeners.add(fn);
375
+ return () => {
376
+ this.listeners.delete(fn);
377
+ };
378
+ }
379
+ batch(upserts, removed) {
380
+ return {
381
+ schemaVersion: 1,
382
+ epoch: this.epoch,
383
+ sequence: this.sequence,
384
+ generatedAt: this.now(),
385
+ health: this.health(),
386
+ upserts,
387
+ removed
388
+ };
389
+ }
390
+ flush() {
391
+ const upserts = [], removed = [];
392
+ const resident = this.sessions();
393
+ for (const [id, old] of this.rows)
394
+ if (old.measurement && old.policy.reason !== "stale-usage" && this.now() - old.measurement.at > this.config.safety.staleMs)
395
+ this.mark(id);
396
+ for (const id of resident.keys())
397
+ if (!this.rows.has(id))
398
+ this.mark(id);
399
+ for (const id of this.dirty) {
400
+ const s = resident.get(id);
401
+ if (!s)
402
+ continue;
403
+ const next = project(s, this.config, this.now());
404
+ const old = this.rows.get(id);
405
+ if (old && JSON.stringify({ ...old, revision: 0 }) === JSON.stringify(next))
406
+ continue;
407
+ const row2 = freezeSnapshot({ ...next, revision: ++this.revision });
408
+ this.rows.set(id, row2);
409
+ upserts.push(row2);
410
+ }
411
+ for (const id of this.rows.keys())
412
+ if (!resident.has(id)) {
413
+ this.rows.delete(id);
414
+ removed.push({
415
+ sessionId: id,
416
+ revision: ++this.revision,
417
+ reason: this.deleted.has(id) ? "deleted" : "evicted"
418
+ });
419
+ }
420
+ this.deleted.clear();
421
+ this.dirty.clear();
422
+ const healthKey = JSON.stringify(this.health()), changed = healthKey !== this.healthKey;
423
+ this.healthKey = healthKey;
424
+ const changes = [
425
+ ...upserts.map((row2) => ({ kind: "upsert", row: row2 })),
426
+ ...removed.map((row2) => ({ kind: "remove", row: row2 }))
427
+ ];
428
+ for (let i = 0;i < Math.max(changes.length, changed ? 1 : 0); i += 128) {
429
+ this.sequence++;
430
+ const slice = changes.slice(i, i + 128);
431
+ const batch = this.batch(slice.flatMap((change) => change.kind === "upsert" ? [change.row] : []), slice.flatMap((change) => change.kind === "remove" ? [change.row] : []));
432
+ for (const fn of this.listeners) {
433
+ try {
434
+ fn(structuredClone(batch));
435
+ } catch {
436
+ }
437
+ }
438
+ }
439
+ }
440
+ read(input) {
441
+ this.flush();
442
+ const ids = [...new Set(input.sessionIds)];
443
+ return structuredClone({
444
+ ...this.batch(ids.flatMap((id) => {
445
+ const row2 = this.rows.get(id);
446
+ return row2 ? [row2] : [];
447
+ }), []),
448
+ unavailable: ids.filter((id) => !this.rows.has(id)).map((sessionId) => ({ sessionId, reason: "not-monitored" }))
449
+ });
450
+ }
451
+ }
452
+ function freezeSnapshot(row2) {
453
+ if (row2.measurement)
454
+ Object.freeze(row2.measurement);
455
+ if (row2.limits)
456
+ Object.freeze(row2.limits);
457
+ Object.freeze(row2.policy.configured);
458
+ Object.freeze(row2.policy);
459
+ Object.freeze(row2.compaction);
460
+ if (row2.cumulative) {
461
+ Object.freeze(row2.cumulative.cache);
462
+ Object.freeze(row2.cumulative);
463
+ }
464
+ return Object.freeze(row2);
465
+ }
466
+
467
+ // src/snapshotserver.ts
468
+ async function serveSnapshots(rpc, store) {
469
+ if (!rpc)
470
+ return () => {
471
+ };
472
+ const registration = await rpc.register(monitorRpc, {
473
+ read: async (input) => {
474
+ if (!isRead(input))
475
+ throw new Error("Invalid snapshot request");
476
+ const result = store.read(input);
477
+ if (!isResult(result))
478
+ throw new Error("Invalid snapshot projection");
479
+ return result;
480
+ }
481
+ });
482
+ const queue = [];
483
+ const capacity = Math.ceil(store.health().maxSessions / 128) + 2;
484
+ let busy = false, stopped = false;
485
+ let retry;
486
+ let pending = Promise.resolve();
487
+ function send() {
488
+ if (busy || stopped || !queue.length)
489
+ return;
490
+ const batch = queue[0];
491
+ if (!batch)
492
+ return;
493
+ busy = true;
494
+ pending = registration.events.emit("changed", { ...batch }).then(() => {
495
+ if (queue[0] === batch)
496
+ queue.shift();
497
+ }, () => {
498
+ if (!stopped)
499
+ scheduleRetry();
500
+ }).finally(() => {
501
+ busy = false;
502
+ if (!retry && !stopped)
503
+ send();
504
+ });
505
+ }
506
+ const unlisten = store.listen((batch) => {
507
+ if (!isBatch(batch))
508
+ return;
509
+ queue.push(batch);
510
+ if (queue.length > capacity)
511
+ queue.shift();
512
+ if (!retry)
513
+ send();
514
+ });
515
+ const retrySend = () => {
516
+ retry = undefined;
517
+ send();
518
+ };
519
+ function scheduleRetry() {
520
+ retry = setTimeout(retrySend, 100);
521
+ }
522
+ return async () => {
523
+ stopped = true;
524
+ unlisten();
525
+ clearTimeout(retry);
526
+ queue.length = 0;
527
+ await pending;
528
+ await registration.dispose();
529
+ };
530
+ }
531
+
532
+ // src/config.ts
533
+ function object2(value, path) {
534
+ if (!value || typeof value !== "object" || Array.isArray(value))
535
+ throw new Error(`${path}: expected object`);
536
+ return value;
537
+ }
538
+ function keys(o, allowed, path) {
539
+ for (const key of Object.keys(o))
540
+ if (!allowed.includes(key))
541
+ throw new Error(`${path}.${key}: unknown option`);
542
+ }
543
+ function number(v, fallback, path, min = 0) {
544
+ if (v === undefined)
545
+ return fallback;
546
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < min)
547
+ throw new Error(`${path}: expected integer >= ${min}`);
548
+ return v;
549
+ }
550
+ function bool(v, fallback, path) {
551
+ if (v === undefined)
552
+ return fallback;
553
+ if (typeof v !== "boolean")
554
+ throw new Error(`${path}: expected boolean`);
555
+ return v;
556
+ }
557
+ function selector(v, path) {
558
+ if (typeof v !== "string" || !v.length || v.length > 256 || /[\[\]{}\\]/.test(v))
559
+ throw new Error(`${path}: expected selector (only * and ? wildcards)`);
560
+ return v;
561
+ }
562
+ function threshold(v, path) {
563
+ const o = object2(v, path);
564
+ keys(o, ["type", "value"], path);
565
+ if (o.type !== "tokens" && o.type !== "ratio" && o.type !== "remaining")
566
+ throw new Error(`${path}.type: invalid threshold`);
567
+ if (typeof o.value !== "number" || !Number.isFinite(o.value) || o.value <= 0 || (o.type === "ratio" ? o.value > 1 : !Number.isSafeInteger(o.value)))
568
+ throw new Error(`${path}.value: invalid threshold`);
569
+ return { type: o.type, value: o.value };
570
+ }
571
+ function parseConfig(value = {}) {
572
+ const o = object2(value, "options");
573
+ keys(o, ["enabled", "mode", "defaults", "policies", "safety", "telemetry"], "options");
574
+ if (o.mode !== undefined && o.mode !== "observe" && o.mode !== "enforce")
575
+ throw new Error("mode: expected observe or enforce");
576
+ const d = object2(o.defaults ?? {}, "defaults");
577
+ keys(d, ["threshold"], "defaults");
578
+ const s = object2(o.safety ?? {}, "safety");
579
+ keys(s, [
580
+ "cooldownMs",
581
+ "minReclaimTokens",
582
+ "maxConsecutiveFailures",
583
+ "pendingTimeoutMs",
584
+ "staleMs",
585
+ "maxSessions",
586
+ "sessionTtlMs",
587
+ "outputReserveTokens"
588
+ ], "safety");
589
+ const t = object2(o.telemetry ?? {}, "telemetry");
590
+ keys(t, ["enabled", "jsonl", "path", "maxBytes"], "telemetry");
591
+ if (t.path !== undefined && (typeof t.path !== "string" || !t.path.startsWith("/")))
592
+ throw new Error("telemetry.path: expected absolute path");
593
+ if (o.policies !== undefined && (!Array.isArray(o.policies) || o.policies.length > 256))
594
+ throw new Error("policies: expected array of at most 256 policies");
595
+ const policies = (o.policies ?? []).map((v, i) => {
596
+ const p = object2(v, `policies[${i}]`);
597
+ keys(p, ["match", "threshold"], `policies[${i}]`);
598
+ const m = object2(p.match, "match");
599
+ keys(m, ["role", "model", "agent"], "match");
600
+ if (!Object.keys(m).length)
601
+ throw new Error("match: at least one selector required");
602
+ const match = {};
603
+ if (m.role !== undefined) {
604
+ if (m.role !== "main" && m.role !== "subagent")
605
+ throw new Error("match.role: invalid");
606
+ match.role = m.role;
607
+ }
608
+ if (m.model !== undefined) {
609
+ match.model = selector(m.model, "match.model");
610
+ if (!match.model.includes("/"))
611
+ throw new Error("match.model: use provider/model");
612
+ }
613
+ if (m.agent !== undefined)
614
+ match.agent = selector(m.agent, "match.agent");
615
+ return { match, threshold: threshold(p.threshold, "threshold") };
616
+ });
617
+ const config = {
618
+ enabled: bool(o.enabled, true, "enabled"),
619
+ mode: o.mode ?? "observe",
620
+ defaults: {
621
+ threshold: threshold(d.threshold ?? { type: "tokens", value: 131072 }, "defaults.threshold")
622
+ },
623
+ policies,
624
+ safety: {
625
+ cooldownMs: number(s.cooldownMs, 60000, "cooldownMs"),
626
+ minReclaimTokens: number(s.minReclaimTokens, 8192, "minReclaimTokens"),
627
+ maxConsecutiveFailures: number(s.maxConsecutiveFailures, 3, "maxConsecutiveFailures", 1),
628
+ pendingTimeoutMs: number(s.pendingTimeoutMs, 300000, "pendingTimeoutMs", 1),
629
+ staleMs: number(s.staleMs, 300000, "staleMs", 1),
630
+ maxSessions: number(s.maxSessions, 1024, "maxSessions", 1),
631
+ sessionTtlMs: number(s.sessionTtlMs, 86400000, "sessionTtlMs", 1),
632
+ ...s.outputReserveTokens === undefined ? {} : {
633
+ outputReserveTokens: number(s.outputReserveTokens, 0, "outputReserveTokens")
634
+ }
635
+ },
636
+ telemetry: {
637
+ enabled: bool(t.enabled, true, "telemetry.enabled"),
638
+ jsonl: bool(t.jsonl, true, "telemetry.jsonl"),
639
+ maxBytes: number(t.maxBytes, 8 * 1024 * 1024, "maxBytes", 4096),
640
+ ...typeof t.path === "string" ? { path: t.path } : {}
641
+ }
642
+ };
643
+ for (const policy of [config.defaults, ...config.policies]) {
644
+ if (policy.threshold.type === "tokens" && config.safety.minReclaimTokens >= policy.threshold.value)
645
+ throw new Error("minReclaimTokens must be smaller than each absolute threshold");
646
+ }
647
+ if (!config.telemetry.jsonl && config.telemetry.path)
648
+ throw new Error("telemetry.path requires jsonl");
649
+ return config;
650
+ }
651
+
652
+ // src/types.ts
653
+ class NoopBudgetController {
654
+ evaluate(_input) {
655
+ return { type: "continue" };
656
+ }
657
+ }
658
+
659
+ // src/monitor.ts
660
+ function promptTokens(t) {
661
+ const fields = [t.input, t.output, t.reasoning, t.cache.read, t.cache.write];
662
+ if (fields.some((x) => !Number.isSafeInteger(x) || x < 0))
663
+ return;
664
+ const total = t.input + t.cache.read + t.cache.write;
665
+ return total > 0 && Number.isSafeInteger(total) ? total : undefined;
666
+ }
667
+
668
+ class ContextMonitor {
669
+ config;
670
+ scheduler;
671
+ sink;
672
+ now;
673
+ budget;
674
+ sessions = new Map;
675
+ resolver;
676
+ constructor(config, scheduler, sink, now = Date.now, budget = new NoopBudgetController) {
677
+ this.config = config;
678
+ this.scheduler = scheduler;
679
+ this.sink = sink;
680
+ this.now = now;
681
+ this.budget = budget;
682
+ this.resolver = new PolicyResolver(config);
683
+ }
684
+ touch(identity, limits) {
685
+ let s = this.sessions.get(identity.sessionId);
686
+ if (!s) {
687
+ this.cleanup();
688
+ if (this.sessions.size >= this.config.safety.maxSessions) {
689
+ this.sink.emit({
690
+ time: this.now(),
691
+ type: "capacity-exceeded",
692
+ detail: "New session not monitored; increase safety.maxSessions"
693
+ });
694
+ return;
695
+ }
696
+ s = {
697
+ ...identity,
698
+ lastSeen: this.now(),
699
+ sequence: -1,
700
+ active: false,
701
+ usage: {
702
+ cumulativeScope: "unknown",
703
+ peak: 0,
704
+ modelRequests: 0,
705
+ toolCalls: 0,
706
+ countersSince: this.now()
707
+ },
708
+ compaction: {
709
+ phase: "idle",
710
+ armed: true,
711
+ count: 0,
712
+ failures: 0,
713
+ consecutiveFailures: 0
714
+ }
715
+ };
716
+ this.sessions.set(identity.sessionId, s);
717
+ }
718
+ if (identity.model && identity.model !== s.model) {
719
+ delete s.usage.current;
720
+ delete s.limits;
721
+ }
722
+ Object.assign(s, identity);
723
+ s.lastSeen = this.now();
724
+ if (limits)
725
+ s.limits = { ...limits };
726
+ return s;
727
+ }
728
+ accept(s, sequence) {
729
+ if (sequence <= s.sequence)
730
+ return false;
731
+ s.sequence = sequence;
732
+ s.lastSeen = this.now();
733
+ return true;
734
+ }
735
+ measure(s, measurement2, cumulative) {
736
+ if (s.usage.current && measurement2.at < s.usage.current.at)
737
+ return;
738
+ s.usage.current = measurement2;
739
+ s.usage.peak = Math.max(s.usage.peak, measurement2.tokens);
740
+ if (cumulative) {
741
+ s.usage.cumulative = cumulative;
742
+ s.usage.cumulativeScope = "session";
743
+ }
744
+ this.evaluate(s);
745
+ }
746
+ evaluate(s) {
747
+ const p = this.resolver.resolve(s);
748
+ s.decision = evaluateThreshold(p.threshold, s.usage.current, s.limits, this.now(), this.config.safety.staleMs, s.model, p.policy, this.config.safety.outputReserveTokens);
749
+ this.scheduler.measured(s);
750
+ const action = this.budget.evaluate({
751
+ session: s,
752
+ now: this.now(),
753
+ elapsedMs: this.now() - s.usage.countersSince
754
+ });
755
+ if (action.type !== "continue")
756
+ this.sink.emit({
757
+ time: this.now(),
758
+ type: `budget.${action.type}`,
759
+ session: s,
760
+ detail: "Advisory extension; only context policies execute in this release"
761
+ });
762
+ this.sink.emit({
763
+ time: this.now(),
764
+ type: this.config.mode === "observe" && s.decision.crossed ? "would-compact" : "usage",
765
+ session: s
766
+ });
767
+ this.scheduler.consider(s);
768
+ }
769
+ remove(id) {
770
+ const s = this.sessions.get(id);
771
+ if (s) {
772
+ s.active = false;
773
+ s.compaction.phase = "blocked";
774
+ s.compaction.blockedReason = "session-removed";
775
+ this.sink.emit({ time: this.now(), type: "session.removed", session: s });
776
+ this.sessions.delete(id);
777
+ }
778
+ }
779
+ cleanup() {
780
+ for (const [id, s] of this.sessions) {
781
+ this.scheduler.tick(s);
782
+ if (!s.active && ["idle", "blocked"].includes(s.compaction.phase) && this.now() - s.lastSeen > this.config.safety.sessionTtlMs)
783
+ this.remove(id);
784
+ }
785
+ }
786
+ async getUsage(id) {
787
+ const s = this.sessions.get(id);
788
+ if (!s)
789
+ throw new Error("Unknown session");
790
+ return structuredClone(s.usage);
791
+ }
792
+ }
793
+
794
+ // src/scheduler.ts
795
+ class CompactionScheduler {
796
+ config;
797
+ port;
798
+ sink;
799
+ now;
800
+ id;
801
+ stopped = false;
802
+ jobs = new Set;
803
+ constructor(config, port, sink, now = Date.now, id = () => `msg_${crypto.randomUUID().replaceAll("-", "")}`) {
804
+ this.config = config;
805
+ this.port = port;
806
+ this.sink = sink;
807
+ this.now = now;
808
+ this.id = id;
809
+ }
810
+ consider(s) {
811
+ if (this.stopped || !this.config.enabled || this.config.mode !== "enforce" || !s.decision?.crossed)
812
+ return;
813
+ const c = s.compaction, time = this.now();
814
+ if (c.phase !== "idle" || !c.armed || c.consecutiveFailures >= this.config.safety.maxConsecutiveFailures)
815
+ return;
816
+ if (c.lastAttempt !== undefined && time - c.lastAttempt < this.config.safety.cooldownMs)
817
+ return;
818
+ c.phase = "requesting";
819
+ c.lastAttempt = time;
820
+ c.requestId = this.id();
821
+ c.before = s.usage.current?.tokens;
822
+ c.beforeModel = s.model;
823
+ c.beforeConfidence = s.usage.current?.confidence;
824
+ c.armed = false;
825
+ this.emit("compaction.requesting", s);
826
+ const requestId = c.requestId;
827
+ const job = new Promise((resolve) => setTimeout(resolve, 0)).then(async () => {
828
+ if (this.stopped || c.phase !== "requesting")
829
+ return;
830
+ try {
831
+ await this.port.compact(s.sessionId, requestId);
832
+ if (c.phase === "requesting")
833
+ c.phase = "accepted";
834
+ this.emit("compaction.accepted", s);
835
+ } catch {
836
+ if (c.phase === "requesting") {
837
+ c.phase = "uncertain";
838
+ c.blockedReason = "admission-outcome-unknown";
839
+ c.failures++;
840
+ c.consecutiveFailures++;
841
+ this.emit("compaction.uncertain", s);
842
+ }
843
+ }
844
+ }).finally(() => this.jobs.delete(job));
845
+ this.jobs.add(job);
846
+ }
847
+ lifecycle(s, event, at) {
848
+ const c = s.compaction;
849
+ if (event === "started") {
850
+ if (c.phase !== "requesting" && c.phase !== "accepted") {
851
+ c.before = s.usage.current?.tokens;
852
+ c.beforeModel = s.model;
853
+ c.beforeConfidence = s.usage.current?.confidence;
854
+ }
855
+ c.phase = "running";
856
+ c.armed = false;
857
+ c.lastAttempt = at;
858
+ } else if (event === "ended") {
859
+ c.phase = "awaiting-measurement";
860
+ c.lastCompleted = at;
861
+ c.count++;
862
+ c.consecutiveFailures = 0;
863
+ delete s.usage.current;
864
+ } else {
865
+ c.failures++;
866
+ c.consecutiveFailures++;
867
+ c.lastAttempt = at;
868
+ c.armed = true;
869
+ c.phase = c.consecutiveFailures >= this.config.safety.maxConsecutiveFailures ? "blocked" : "idle";
870
+ c.blockedReason = "compaction-failed";
871
+ delete s.usage.current;
872
+ }
873
+ this.emit(`compaction.${event}`, s);
874
+ }
875
+ measured(s) {
876
+ const c = s.compaction, m = s.usage.current;
877
+ if (!m)
878
+ return;
879
+ if (c.phase === "awaiting-measurement") {
880
+ if (m.confidence !== "reported" || m.at < (c.lastCompleted ?? 0))
881
+ return;
882
+ if (c.beforeModel !== m.model || c.before === undefined || c.beforeConfidence !== "reported") {
883
+ c.phase = "blocked";
884
+ c.blockedReason = "reclaim-unverifiable";
885
+ } else if (c.before - m.tokens < this.config.safety.minReclaimTokens) {
886
+ c.phase = "blocked";
887
+ c.blockedReason = "insufficient-reclaim";
888
+ } else {
889
+ c.phase = "idle";
890
+ c.armed = false;
891
+ delete c.blockedReason;
892
+ }
893
+ }
894
+ const resetAt = (s.decision?.threshold ?? 0) - this.config.safety.minReclaimTokens;
895
+ if (m.tokens <= resetAt && (c.phase === "idle" || c.phase === "blocked" && c.blockedReason !== "compaction-failed")) {
896
+ c.armed = true;
897
+ c.phase = "idle";
898
+ delete c.blockedReason;
899
+ }
900
+ }
901
+ tick(s) {
902
+ const c = s.compaction;
903
+ if ((c.phase === "accepted" || c.phase === "running" || c.phase === "requesting") && this.now() - (c.lastAttempt ?? this.now()) > this.config.safety.pendingTimeoutMs) {
904
+ c.phase = "uncertain";
905
+ c.blockedReason = "completion-event-timeout";
906
+ this.emit("compaction.uncertain", s);
907
+ }
908
+ }
909
+ emit(type, session) {
910
+ this.sink.emit({ type, time: this.now(), session });
911
+ }
912
+ async flush() {
913
+ await Promise.all(this.jobs);
914
+ }
915
+ stop() {
916
+ this.stopped = true;
917
+ }
918
+ }
919
+
920
+ // src/estimate.ts
921
+ function estimate(value, maxChars = 2000000) {
922
+ let chars = 0, nodes = 0;
923
+ function visit(v, depth) {
924
+ if (++nodes > 1e5 || depth > 30 || chars > maxChars)
925
+ return false;
926
+ if (typeof v === "string")
927
+ chars += v.length;
928
+ else if (typeof v === "number" || typeof v === "boolean")
929
+ chars += 8;
930
+ else if (Array.isArray(v)) {
931
+ for (const item of v)
932
+ if (!visit(item, depth + 1))
933
+ return false;
934
+ } else if (v && typeof v === "object") {
935
+ if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer)
936
+ return false;
937
+ for (const [key, item] of Object.entries(v)) {
938
+ chars += key.length + 4;
939
+ if (!visit(item, depth + 1))
940
+ return false;
941
+ }
942
+ }
943
+ return chars <= maxChars;
944
+ }
945
+ return visit(value, 0) ? Math.ceil(chars / 3) : undefined;
946
+ }
947
+
948
+ // src/recovery.ts
949
+ function record2(v) {
950
+ return typeof v === "object" && v !== null && !Array.isArray(v);
951
+ }
952
+ function nonnegative(v) {
953
+ return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
954
+ }
955
+ function decodeRecovery(v, max) {
956
+ if (!Array.isArray(v))
957
+ return [];
958
+ const result = [];
959
+ for (const item of v.slice(0, max)) {
960
+ if (!record2(item) || typeof item.sessionId !== "string" || !record2(item.compaction))
961
+ continue;
962
+ const c = item.compaction;
963
+ if (!nonnegative(c.count) || !nonnegative(c.failures) || !nonnegative(c.consecutiveFailures) || typeof c.armed !== "boolean")
964
+ continue;
965
+ const phase = c.phase === "idle" || c.phase === "blocked" ? c.phase : "uncertain";
966
+ const compaction = {
967
+ phase,
968
+ armed: c.armed,
969
+ count: c.count,
970
+ failures: c.failures,
971
+ consecutiveFailures: c.consecutiveFailures
972
+ };
973
+ if (nonnegative(c.lastAttempt))
974
+ compaction.lastAttempt = c.lastAttempt;
975
+ if (nonnegative(c.lastCompleted))
976
+ compaction.lastCompleted = c.lastCompleted;
977
+ if (typeof c.requestId === "string")
978
+ compaction.requestId = c.requestId;
979
+ if (typeof c.blockedReason === "string")
980
+ compaction.blockedReason = c.blockedReason;
981
+ if (phase === "uncertain")
982
+ compaction.blockedReason = "restart-needs-lifecycle-evidence";
983
+ result.push({
984
+ sessionId: item.sessionId,
985
+ compaction,
986
+ ...nonnegative(item.sequence) ? { sequence: item.sequence } : {}
987
+ });
988
+ }
989
+ return result;
990
+ }
991
+ function encodeRecovery(rows) {
992
+ return rows.map(({ sessionId, compaction: c, sequence }) => ({
993
+ sessionId,
994
+ sequence: sequence ?? -1,
995
+ compaction: {
996
+ requestId: c.requestId ?? null,
997
+ phase: c.phase,
998
+ armed: c.armed,
999
+ count: c.count,
1000
+ failures: c.failures,
1001
+ consecutiveFailures: c.consecutiveFailures,
1002
+ lastAttempt: c.lastAttempt ?? null,
1003
+ lastCompleted: c.lastCompleted ?? null,
1004
+ blockedReason: c.blockedReason ?? null
1005
+ }
1006
+ }));
1007
+ }
1008
+
1009
+ // src/telemetry.ts
1010
+ import { mkdir, open, rename, stat } from "fs/promises";
1011
+ import { dirname, join } from "path";
1012
+ import { createHash } from "crypto";
1013
+ function defaultPath(directory) {
1014
+ const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
1015
+ return join("/home/julius/.local/state", "opencode-context-guard", `${key}.jsonl`);
1016
+ }
1017
+
1018
+ class JsonlTelemetrySink {
1019
+ path;
1020
+ config;
1021
+ pending = [];
1022
+ working;
1023
+ disabled = false;
1024
+ bytes = 0;
1025
+ ready = false;
1026
+ constructor(path, config) {
1027
+ this.path = path;
1028
+ this.config = config;
1029
+ }
1030
+ emit(event) {
1031
+ if (!this.config.enabled || this.disabled)
1032
+ return;
1033
+ const line = JSON.stringify({ schema: 1, ...event }) + "\n";
1034
+ if (!this.config.jsonl) {
1035
+ console.error(line.trimEnd());
1036
+ return;
1037
+ }
1038
+ if (this.pending.length >= 256) {
1039
+ console.error("context-guard: telemetry queue full; record dropped");
1040
+ return;
1041
+ }
1042
+ this.pending.push(line);
1043
+ this.kick();
1044
+ }
1045
+ kick() {
1046
+ if (!this.working && !this.disabled)
1047
+ this.working = this.drain().finally(() => {
1048
+ this.working = undefined;
1049
+ if (this.pending.length)
1050
+ this.kick();
1051
+ });
1052
+ }
1053
+ async drain() {
1054
+ try {
1055
+ if (!this.ready) {
1056
+ await mkdir(dirname(this.path), { recursive: true, mode: 448 });
1057
+ this.bytes = await stat(this.path).then((s) => s.size).catch(() => 0);
1058
+ this.ready = true;
1059
+ }
1060
+ while (this.pending.length) {
1061
+ const line = this.pending.shift();
1062
+ if (!line)
1063
+ continue;
1064
+ const size = Buffer.byteLength(line);
1065
+ if (size > this.config.maxBytes)
1066
+ continue;
1067
+ if (this.bytes + size > this.config.maxBytes) {
1068
+ await rename(this.path, `${this.path}.1`).catch((e) => {
1069
+ if (e.code !== "ENOENT")
1070
+ throw e;
1071
+ });
1072
+ this.bytes = 0;
1073
+ }
1074
+ const file = await open(this.path, "a", 384);
1075
+ try {
1076
+ await file.writeFile(line);
1077
+ } finally {
1078
+ await file.close();
1079
+ }
1080
+ this.bytes += size;
1081
+ }
1082
+ } catch {
1083
+ this.disabled = true;
1084
+ this.pending = [];
1085
+ console.error("context-guard: telemetry unavailable (check path and permissions)");
1086
+ }
1087
+ }
1088
+ async flush() {
1089
+ while (this.working)
1090
+ await this.working;
1091
+ }
1092
+ }
1093
+
1094
+ // src/adapter.ts
1095
+ async function setup(ctx, injectedSink) {
1096
+ const config = parseConfig(ctx.options);
1097
+ const disk = new JsonlTelemetrySink(config.telemetry.path ?? defaultPath(ctx.location.directory), config.telemetry);
1098
+ const diagnostics = injectedSink ?? disk;
1099
+ const store = new SnapshotStore(config, () => monitor?.sessions ?? new Map);
1100
+ let monitor;
1101
+ const sink = {
1102
+ emit(event) {
1103
+ try {
1104
+ if (event.session)
1105
+ store.mark(event.session.sessionId);
1106
+ if ([
1107
+ "event-stream-gap",
1108
+ "persistence-unavailable",
1109
+ "capacity-exceeded",
1110
+ "model-metadata-unavailable"
1111
+ ].includes(event.type)) {
1112
+ if (event.type === "event-stream-gap")
1113
+ store.degrade("event-stream-gap");
1114
+ if (event.type === "persistence-unavailable")
1115
+ store.degrade("persistence-unavailable");
1116
+ if (event.type === "capacity-exceeded")
1117
+ store.degrade("capacity-exceeded");
1118
+ if (event.type === "model-metadata-unavailable")
1119
+ store.degrade("model-metadata-unavailable");
1120
+ }
1121
+ } catch {
1122
+ }
1123
+ diagnostics.emit(event);
1124
+ }
1125
+ };
1126
+ const stopSnapshots = await serveSnapshots(ctx.rpc, store).catch(() => {
1127
+ diagnostics.emit({
1128
+ time: Date.now(),
1129
+ type: "snapshot-service-unavailable"
1130
+ });
1131
+ return () => {
1132
+ };
1133
+ });
1134
+ if (!config.enabled) {
1135
+ sink.emit({ time: Date.now(), type: "disabled" });
1136
+ return async () => {
1137
+ await stopSnapshots();
1138
+ await disk.flush();
1139
+ };
1140
+ }
1141
+ const controller = new AbortController;
1142
+ let stopped = false, persistenceHealthy = true;
1143
+ let work = Promise.resolve();
1144
+ const serialized = (task) => {
1145
+ const next = work.then(task);
1146
+ work = next.catch(() => {
1147
+ });
1148
+ return next;
1149
+ };
1150
+ const requests = new Map;
1151
+ const saved = await ctx.storage.get("safety").catch(() => {
1152
+ persistenceHealthy = false;
1153
+ return;
1154
+ });
1155
+ const decoded = decodeRecovery(saved, config.safety.maxSessions);
1156
+ if (saved !== undefined && (!Array.isArray(saved) || saved.length !== decoded.length)) {
1157
+ persistenceHealthy = false;
1158
+ }
1159
+ const recoveries = new Map(decoded.map((s) => [s.sessionId, s]));
1160
+ let persistChain = Promise.resolve();
1161
+ const persist = () => {
1162
+ if (!persistenceHealthy)
1163
+ return persistChain;
1164
+ const rows = [...monitor?.sessions.values() ?? []].filter((s) => s.compaction.lastAttempt !== undefined || s.compaction.phase !== "idle").map((s) => ({
1165
+ sessionId: s.sessionId,
1166
+ sequence: s.sequence,
1167
+ compaction: { ...s.compaction }
1168
+ }));
1169
+ const missing = [...recoveries].filter(([id]) => !monitor?.sessions.has(id)).map(([, recovery]) => recovery);
1170
+ const data = encodeRecovery([...rows, ...missing].slice(0, config.safety.maxSessions));
1171
+ persistChain = persistChain.then(() => ctx.storage.set("safety", data)).catch(() => {
1172
+ persistenceHealthy = false;
1173
+ sink.emit({ time: Date.now(), type: "persistence-unavailable" });
1174
+ });
1175
+ return persistChain;
1176
+ };
1177
+ const scheduler = new CompactionScheduler(config, {
1178
+ compact: async (sessionId, id) => {
1179
+ if (!persistenceHealthy)
1180
+ throw new Error("Safety persistence unavailable");
1181
+ await persist();
1182
+ if (!monitor?.sessions.has(sessionId) || monitor?.sessions.get(sessionId)?.compaction.phase !== "requesting")
1183
+ throw new Error("Session no longer requesting");
1184
+ if (!persistenceHealthy || stopped)
1185
+ throw new Error("Safety persistence unavailable");
1186
+ await ctx.session.compact({ sessionID: sessionId, id, delivery: "steer" }, { signal: controller.signal });
1187
+ }
1188
+ }, sink);
1189
+ monitor = new ContextMonitor(config, scheduler, sink);
1190
+ const activeMonitor = monitor;
1191
+ let models = new Map, modelAt = 0, modelRetryAt = 0, modelRetryMs = 1000;
1192
+ async function refreshModels(retryMissing = false) {
1193
+ const now = Date.now();
1194
+ if (now - modelAt < 60000 && (!retryMissing || now < modelRetryAt))
1195
+ return;
1196
+ modelAt = now;
1197
+ modelRetryAt = now + modelRetryMs;
1198
+ modelRetryMs = Math.min(60000, modelRetryMs * 2);
1199
+ try {
1200
+ models = new Map((await ctx.model.list(undefined, { signal: controller.signal })).data.map((m) => [`${m.providerID}/${m.id}`, { ...m.limit }]));
1201
+ let missing = false;
1202
+ for (const s of activeMonitor.sessions.values()) {
1203
+ const limits = s.model ? models.get(s.model) : undefined;
1204
+ if (limits)
1205
+ s.limits = { ...limits };
1206
+ else {
1207
+ delete s.limits;
1208
+ missing ||= s.model !== undefined;
1209
+ }
1210
+ store.mark(s.sessionId);
1211
+ }
1212
+ if (!missing)
1213
+ modelRetryMs = 1000;
1214
+ store.recover("model-metadata-unavailable");
1215
+ } catch {
1216
+ models.clear();
1217
+ for (const s of activeMonitor.sessions.values()) {
1218
+ delete s.limits;
1219
+ store.mark(s.sessionId);
1220
+ }
1221
+ sink.emit({ time: Date.now(), type: "model-metadata-unavailable" });
1222
+ }
1223
+ }
1224
+ async function discover(sessionId) {
1225
+ const info = await ctx.session.get({ sessionID: sessionId }, { signal: controller.signal });
1226
+ if (info.location.directory !== ctx.location.directory)
1227
+ return;
1228
+ const identity = {
1229
+ sessionId,
1230
+ ...info.parentID ? { parentId: info.parentID } : {},
1231
+ ...info.agent ? { agent: info.agent } : {},
1232
+ ...info.model ? { model: `${info.model.providerID}/${info.model.id}` } : {}
1233
+ };
1234
+ if (!activeMonitor.sessions.has(sessionId) && !recoveries.has(sessionId) && activeMonitor.sessions.size + recoveries.size >= config.safety.maxSessions) {
1235
+ sink.emit({ time: Date.now(), type: "capacity-exceeded" });
1236
+ return;
1237
+ }
1238
+ const s = activeMonitor.touch(identity, identity.model ? models.get(identity.model) : undefined);
1239
+ if (s) {
1240
+ if (!identity.model || !models.has(identity.model))
1241
+ delete s.limits;
1242
+ const recovery = recoveries.get(sessionId);
1243
+ if (recovery) {
1244
+ s.compaction = recovery.compaction;
1245
+ s.sequence = recovery.sequence ?? -1;
1246
+ recoveries.delete(sessionId);
1247
+ }
1248
+ s.usage.cumulative = info.tokens;
1249
+ s.usage.cumulativeScope = "session";
1250
+ store.mark(s.sessionId);
1251
+ }
1252
+ return s;
1253
+ }
1254
+ const pending = new Map;
1255
+ let draining = false;
1256
+ async function drainHooks() {
1257
+ if (stopped)
1258
+ return;
1259
+ try {
1260
+ await refreshModels([...activeMonitor.sessions.values()].some((s) => s.model !== undefined && !models.has(s.model)));
1261
+ for (const [id, h] of pending) {
1262
+ pending.delete(id);
1263
+ try {
1264
+ const s = activeMonitor.sessions.get(id) ?? await discover(id);
1265
+ if (!s)
1266
+ continue;
1267
+ if (h.at <= Math.max(s.lastExecutionEnd ?? 0, s.compaction.lastCompleted ?? 0))
1268
+ continue;
1269
+ activeMonitor.touch(h.identity, h.identity.model ? models.get(h.identity.model) : undefined);
1270
+ s.active = true;
1271
+ if ((!s.usage.current || Date.now() - s.usage.current.at > config.safety.staleMs) && h.estimate !== undefined && s.model)
1272
+ activeMonitor.measure(s, {
1273
+ tokens: h.estimate,
1274
+ at: h.at,
1275
+ model: s.model,
1276
+ confidence: "approximate",
1277
+ provenance: "serialized-estimate",
1278
+ scope: "prepared-request"
1279
+ });
1280
+ else
1281
+ activeMonitor.evaluate(s);
1282
+ } catch {
1283
+ sink.emit({ time: Date.now(), type: "session-observation-failed" });
1284
+ }
1285
+ }
1286
+ } finally {
1287
+ draining = false;
1288
+ }
1289
+ }
1290
+ const hook = (event) => {
1291
+ if (stopped)
1292
+ return;
1293
+ const id = event.sessionID, model = `${event.model.providerID}/${event.model.id}`, current = activeMonitor.sessions.get(id)?.usage.current;
1294
+ if (!pending.has(id) && pending.size >= config.safety.maxSessions) {
1295
+ sink.emit({ time: Date.now(), type: "hook-capacity-exceeded" });
1296
+ return;
1297
+ }
1298
+ const fallback = !current || current.model !== model || Date.now() - current.at > config.safety.staleMs;
1299
+ const tokens = fallback ? estimate([event.system, event.messages, event.tools]) : undefined;
1300
+ pending.set(id, {
1301
+ identity: { sessionId: id, agent: event.agent, model },
1302
+ at: Date.now(),
1303
+ ...tokens === undefined ? {} : { estimate: tokens }
1304
+ });
1305
+ };
1306
+ const registrations = [];
1307
+ try {
1308
+ registrations.push(await ctx.session.hook("context", hook));
1309
+ registrations.push(await ctx.session.hook("compaction", (e) => {
1310
+ const s = activeMonitor.sessions.get(e.sessionID);
1311
+ if (s) {
1312
+ s.compaction.phase = "running";
1313
+ s.compaction.armed = false;
1314
+ store.mark(s.sessionId);
1315
+ }
1316
+ }));
1317
+ } catch (error) {
1318
+ for (const r of registrations)
1319
+ await r.dispose();
1320
+ await stopSnapshots();
1321
+ throw error;
1322
+ }
1323
+ const relevant = new Set([
1324
+ "session.created",
1325
+ "session.step.started",
1326
+ "session.step.ended",
1327
+ "session.step.failed",
1328
+ "session.compaction.started",
1329
+ "session.compaction.ended",
1330
+ "session.compaction.failed",
1331
+ "session.execution.started",
1332
+ "session.execution.succeeded",
1333
+ "session.execution.failed",
1334
+ "session.execution.interrupted",
1335
+ "session.deleted",
1336
+ "session.model.selected",
1337
+ "session.agent.selected",
1338
+ "session.tool.called"
1339
+ ]);
1340
+ async function handle(event) {
1341
+ if (!relevant.has(event.type) || !("sessionID" in event.data))
1342
+ return;
1343
+ const id = event.data.sessionID;
1344
+ if (event.location && event.location.directory !== ctx.location.directory)
1345
+ return;
1346
+ if (event.type === "session.deleted") {
1347
+ store.remove(id);
1348
+ requests.delete(id);
1349
+ activeMonitor.remove(id);
1350
+ recoveries.delete(id);
1351
+ await persist();
1352
+ return;
1353
+ }
1354
+ await refreshModels();
1355
+ const s = await discover(id);
1356
+ if (!s)
1357
+ return;
1358
+ if ("durable" in event && event.durable && !activeMonitor.accept(s, event.durable.seq))
1359
+ return;
1360
+ switch (event.type) {
1361
+ case "session.step.started":
1362
+ activeMonitor.touch({
1363
+ sessionId: id,
1364
+ agent: event.data.agent,
1365
+ model: `${event.data.model.providerID}/${event.data.model.id}`
1366
+ }, models.get(`${event.data.model.providerID}/${event.data.model.id}`));
1367
+ requests.set(id, {
1368
+ id: event.data.assistantMessageID,
1369
+ model: `${event.data.model.providerID}/${event.data.model.id}`
1370
+ });
1371
+ s.usage.modelRequests++;
1372
+ s.active = true;
1373
+ break;
1374
+ case "session.step.ended":
1375
+ case "session.step.failed": {
1376
+ const tokens = event.data.tokens && promptTokens(event.data.tokens);
1377
+ const request = requests.get(id);
1378
+ if (tokens !== undefined && request?.id === event.data.assistantMessageID && request.model === s.model)
1379
+ activeMonitor.measure(s, {
1380
+ tokens,
1381
+ at: event.created,
1382
+ requestId: event.data.assistantMessageID,
1383
+ model: request.model,
1384
+ confidence: "reported",
1385
+ provenance: "provider-normalized",
1386
+ scope: "completed-request"
1387
+ });
1388
+ else {
1389
+ delete s.usage.current;
1390
+ sink.emit({
1391
+ time: Date.now(),
1392
+ type: "provider-usage-unavailable",
1393
+ session: s
1394
+ });
1395
+ }
1396
+ break;
1397
+ }
1398
+ case "session.compaction.started":
1399
+ scheduler.lifecycle(s, "started", event.created);
1400
+ break;
1401
+ case "session.compaction.ended":
1402
+ scheduler.lifecycle(s, "ended", event.created);
1403
+ break;
1404
+ case "session.compaction.failed":
1405
+ scheduler.lifecycle(s, "failed", event.created);
1406
+ break;
1407
+ case "session.execution.started":
1408
+ s.active = true;
1409
+ break;
1410
+ case "session.execution.succeeded":
1411
+ case "session.execution.failed":
1412
+ case "session.execution.interrupted":
1413
+ s.active = false;
1414
+ s.lastExecutionEnd = event.created;
1415
+ break;
1416
+ case "session.tool.called":
1417
+ s.usage.toolCalls++;
1418
+ break;
1419
+ }
1420
+ sink.emit({ time: Date.now(), type: "session-state", session: s });
1421
+ await persist();
1422
+ }
1423
+ const timer = setInterval(() => {
1424
+ store.flush();
1425
+ if (draining || stopped)
1426
+ return;
1427
+ draining = true;
1428
+ serialized(async () => {
1429
+ await drainHooks();
1430
+ activeMonitor.cleanup();
1431
+ if (activeMonitor.sessions.size + recoveries.size < config.safety.maxSessions)
1432
+ store.recover("capacity-exceeded");
1433
+ for (const id of requests.keys())
1434
+ if (!activeMonitor.sessions.has(id))
1435
+ requests.delete(id);
1436
+ }).catch(() => {
1437
+ draining = false;
1438
+ });
1439
+ }, 100);
1440
+ timer.unref();
1441
+ const events = (async () => {
1442
+ while (!stopped) {
1443
+ try {
1444
+ for await (const event of ctx.event.subscribe({
1445
+ signal: controller.signal
1446
+ })) {
1447
+ if (stopped)
1448
+ break;
1449
+ await serialized(() => handle(event));
1450
+ }
1451
+ } catch {
1452
+ }
1453
+ if (!stopped) {
1454
+ sink.emit({
1455
+ time: Date.now(),
1456
+ type: "event-stream-gap",
1457
+ detail: "Enforcement suspended until reload; measurements may be incomplete"
1458
+ });
1459
+ scheduler.stop();
1460
+ await new Promise((resolve) => {
1461
+ const finish = () => {
1462
+ clearTimeout(t);
1463
+ controller.signal.removeEventListener("abort", finish);
1464
+ resolve();
1465
+ };
1466
+ const t = setTimeout(finish, 1000);
1467
+ controller.signal.addEventListener("abort", finish, { once: true });
1468
+ });
1469
+ }
1470
+ }
1471
+ })();
1472
+ if (!persistenceHealthy)
1473
+ sink.emit({
1474
+ time: Date.now(),
1475
+ type: "persistence-unavailable",
1476
+ detail: "Enforcement disabled; safety state could not be read"
1477
+ });
1478
+ if (!injectedSink)
1479
+ console.error(`context-guard: ${config.mode}; diagnostics ${disk.path}`);
1480
+ sink.emit({
1481
+ time: Date.now(),
1482
+ type: "ready",
1483
+ detail: `SDK 2.0.29; runtime ${ctx.app.version}; ${config.mode}; active-subagent=step-boundary; next-request-barrier=false; journal=${disk.path}`
1484
+ });
1485
+ return async () => {
1486
+ stopped = true;
1487
+ scheduler.stop();
1488
+ controller.abort();
1489
+ clearInterval(timer);
1490
+ pending.clear();
1491
+ const disposed = await Promise.allSettled(registrations.map((r) => r.dispose()));
1492
+ if (disposed.some((r) => r.status === "rejected"))
1493
+ sink.emit({ time: Date.now(), type: "registration-cleanup-failed" });
1494
+ await events;
1495
+ await work;
1496
+ await scheduler.flush();
1497
+ await persist();
1498
+ await stopSnapshots();
1499
+ await disk.flush();
1500
+ };
1501
+ }
1502
+
1503
+ // src/index.ts
1504
+ var src_default = Plugin.define({ id: "context-guard", setup });
1505
+ export {
1506
+ src_default as default
1507
+ };