@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,671 @@
1
+ import type { LiveModel } from "../core/models/registry.js"
2
+ import { readOpenCodeHistory } from "../core/history/opencode.js"
3
+ import { LedgerStore } from "../core/quota/ledger.js"
4
+ import { QuotaService } from "../core/quota/service.js"
5
+ import { discoverOpenDesignModels, toLiveModels } from "../core/providers/opendesign.js"
6
+ import type { ProviderQuotaSnapshot, RouterSettings, UsageEvent } from "../core/types.js"
7
+ import { safeError } from "../core/util.js"
8
+
9
+ /**
10
+ * One shared core per OpenCode server process. The SDK probe confirmed that
11
+ * globalThis is shared across every project-location plugin instance in one
12
+ * server process. Commands/RPC remain location-scoped, so each location gets a
13
+ * light bridge that calls this core.
14
+ */
15
+ export interface RuntimeOverrides {
16
+ mode?: RouterSettings["mode"]
17
+ paused?: boolean
18
+ dryRun?: boolean
19
+ premiumBudgetUsd?: number
20
+ reserves?: Record<string, { monthlyPct?: number; weeklyPct?: number; fiveHourPct?: number }>
21
+ excluded?: string[]
22
+ approved?: string[]
23
+ titleIndicator?: boolean
24
+ compactMode?: boolean
25
+ compact?: boolean
26
+ }
27
+
28
+ export interface CoreDiagnostics {
29
+ coreID: string
30
+ pid: number
31
+ createdAt: number
32
+ uptimeMs: number
33
+ bridges: Array<{ id: string; directory: string; createdAt: number }>
34
+ bridgeCount: number
35
+ setups: number
36
+ disposes: number
37
+ ledgerEvents: number
38
+ decisions: number
39
+ sessions: number
40
+ quotaSnapshots: number
41
+ quotaRefreshes: number
42
+ ledgerWrites: number
43
+ quotaWrites: number
44
+ lastLedgerWriteAt?: number
45
+ lastQuotaWriteAt?: number
46
+ quotaInitialized: boolean
47
+ bootstrapped: boolean
48
+ eventsHandled: number
49
+ eventsIgnored: number
50
+ eventStreams: number
51
+ errors: string[]
52
+ }
53
+
54
+ export interface InitInput {
55
+ readonly ctx: any
56
+ readonly getSettings: () => RouterSettings
57
+ readonly getCredentials: () => Promise<{
58
+ goAccessToken?: string
59
+ commandCodeIntegrationKey?: string
60
+ commandCodeProviderKey?: string
61
+ openDesignApiKey?: string
62
+ openDesignControlKey?: string
63
+ }>
64
+ }
65
+
66
+ const KEY = Symbol.for("opencode-smart-router.shared-core.v1")
67
+ const BRIDGE_GRACE_MS = 10_000
68
+ const MODEL_TTL_MS = 5 * 60_000
69
+ const UNOWNED_TTL_MS = 30_000
70
+ const UNOWNED_MAX = 2_000
71
+ const STORAGE_LEDGER = "ledger-v1"
72
+ const STORAGE_RUNTIME = "runtime-v1"
73
+ const STORAGE_QUOTA = "quota-cache-v1"
74
+
75
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
76
+ typeof value === "object" && value !== null && !Array.isArray(value)
77
+
78
+ const safe = <T>(value: T): any => JSON.parse(JSON.stringify(value))
79
+
80
+ class SharedCore {
81
+ readonly coreID = Math.random().toString(36).slice(2, 10)
82
+ readonly pid = process.pid
83
+ readonly createdAt = Date.now()
84
+ ledger = new LedgerStore()
85
+ runtime: RuntimeOverrides = {}
86
+ quota?: QuotaService
87
+ private readonly bridges = new Map<string, { id: string; directory: string; createdAt: number; controller?: any }>()
88
+ private eventAbort?: AbortController
89
+ private eventsStarted = false
90
+ private readonly owners = new Map<string, string>()
91
+ private readonly unowned = new Map<string, number>()
92
+ private liveModels: LiveModel[] = []
93
+ private modelsLoadedAt = 0
94
+ private modelsLoading?: Promise<LiveModel[]>
95
+ private openDesignModels: LiveModel[] = []
96
+ private openDesignLoadedAt = 0
97
+ private openDesignLoading?: Promise<LiveModel[]>
98
+ private eventsHandled = 0
99
+ private eventsIgnored = 0
100
+ private ownerCtx?: any
101
+ private getSettings?: () => RouterSettings
102
+ private getCredentials?: InitInput["getCredentials"]
103
+ private ready?: Promise<void>
104
+ private loading?: Promise<void>
105
+ private disposing?: Promise<void>
106
+ private quotaInitial: ProviderQuotaSnapshot[] = []
107
+ private ledgerTimer?: ReturnType<typeof setTimeout>
108
+ private quotaTimer?: ReturnType<typeof setTimeout>
109
+ private runtimeTimer?: ReturnType<typeof setTimeout>
110
+ private bootstrapTimer?: ReturnType<typeof setTimeout>
111
+ private quotaStartTimer?: ReturnType<typeof setTimeout>
112
+ private shutdownTimer?: ReturnType<typeof setTimeout>
113
+ private started = false
114
+ private quotaInitialized = false
115
+ private bootstrapped = false
116
+ private ledgerWrites = 0
117
+ private quotaWrites = 0
118
+ private quotaRefreshes = 0
119
+ private lastLedgerWriteAt?: number
120
+ private lastQuotaWriteAt?: number
121
+ private readonly errors: string[] = []
122
+ private pushError(message: string) {
123
+ this.errors.push(message)
124
+ if (this.errors.length > 50) this.errors.splice(0, this.errors.length - 50)
125
+ }
126
+ private sequence = 0
127
+ private setups = 0
128
+ private disposes = 0
129
+
130
+ async init(input: InitInput): Promise<void> {
131
+ this.setups += 1
132
+ if (this.disposing) await this.disposing
133
+ if (this.ready && this.ownerCtx && this.started) return this.ready
134
+ // Several locations can initialize in the same tick when a server restores
135
+ // its session list. Share one load; a second load would replace the ledger
136
+ // and quota service under a bridge that already captured the first ones.
137
+ if (this.loading) return this.loading
138
+ this.ownerCtx = input.ctx
139
+ this.getSettings = input.getSettings
140
+ this.getCredentials = input.getCredentials
141
+ this.loading = this.load().finally(() => {
142
+ this.loading = undefined
143
+ })
144
+ this.ready = this.loading
145
+ return this.loading
146
+ }
147
+
148
+ private async load() {
149
+ try {
150
+ const [savedLedger, savedRuntime, savedQuota] = await Promise.all([
151
+ this.ownerCtx.storage.get(STORAGE_LEDGER).catch(() => undefined),
152
+ this.ownerCtx.storage.get(STORAGE_RUNTIME).catch(() => undefined),
153
+ this.ownerCtx.storage.get(STORAGE_QUOTA).catch(() => undefined),
154
+ ])
155
+ if (isRecord(savedRuntime)) this.runtime = savedRuntime as RuntimeOverrides
156
+ const hydrated = LedgerStore.hydrate(savedLedger)
157
+ this.ledger = new LedgerStore(hydrated.ledger)
158
+ for (const warning of hydrated.warnings.slice(0, 5)) this.pushError(warning)
159
+ this.quotaInitial = Array.isArray(savedQuota) ? (savedQuota as ProviderQuotaSnapshot[]) : []
160
+ this.quota = new QuotaService({
161
+ getSettings: () => (this.getSettings?.().providers ?? {}) as any,
162
+ getCredentials: () => this.getCredentials?.() ?? Promise.resolve({}),
163
+ getUsageEvents: (window) => this.ledger.usageEvents(window),
164
+ probeIntervalMinutes: this.getSettings?.().quota.probeIntervalMinutes ?? 5,
165
+ initial: this.quotaInitial,
166
+ onSnapshot: (snapshot) => {
167
+ this.quotaRefreshes += 1
168
+ this.persistQuotaSoon()
169
+ },
170
+ })
171
+ this.started = true
172
+ // Never touch the network or enumerate sessions on the plugin-load path.
173
+ this.bootstrapTimer = setTimeout(() => {
174
+ void this.bootstrap()
175
+ }, 1_000)
176
+ this.quotaStartTimer = setTimeout(() => {
177
+ void this.startQuota()
178
+ }, 1_500)
179
+ } catch (error) {
180
+ this.pushError(safeError(error))
181
+ }
182
+ if (!this.quota) {
183
+ this.quota = new QuotaService({
184
+ getSettings: () => (this.getSettings?.().providers ?? {}) as any,
185
+ getCredentials: () => this.getCredentials?.() ?? Promise.resolve({}),
186
+ getUsageEvents: (window) => this.ledger.usageEvents(window),
187
+ probeIntervalMinutes: this.getSettings?.().quota.probeIntervalMinutes ?? 5,
188
+ initial: this.quotaInitial,
189
+ onSnapshot: () => {
190
+ this.quotaRefreshes += 1
191
+ this.persistQuotaSoon()
192
+ },
193
+ })
194
+ }
195
+ if (!this.started) {
196
+ this.started = true
197
+ this.bootstrapTimer = setTimeout(() => {
198
+ void this.bootstrap()
199
+ }, 1_000)
200
+ this.quotaStartTimer = setTimeout(() => {
201
+ void this.startQuota()
202
+ }, 1_500)
203
+ }
204
+ }
205
+
206
+ private async bootstrap() {
207
+ if (this.bootstrapped) return
208
+ this.bootstrapped = true
209
+ try {
210
+ const settings = this.getSettings?.()
211
+ if (!settings) return
212
+ const limit = settings.quota.bootstrapSessions
213
+ if (limit <= 0) return
214
+
215
+ /**
216
+ * Prefer OpenCode's own message history.
217
+ *
218
+ * It is strictly better than session summaries: every message carries its
219
+ * own timestamp, which is what a fixed seven-day window needs, and the
220
+ * model that actually served it rather than whatever the session has
221
+ * selected now. When it yields anything at all, the session-level import
222
+ * is skipped — mixing the two would double-count every session.
223
+ */
224
+ const history = readOpenCodeHistory({
225
+ sinceMs: Date.now() - settings.quota.bootstrapDays * 24 * 60 * 60 * 1000,
226
+ limit: Math.max(limit, 1) * 100,
227
+ })
228
+ if (history.entries.length > 0) {
229
+ const imported = this.ledger.importHistory(history.entries)
230
+ if (imported.added > 0) {
231
+ this.persistSoon()
232
+ return
233
+ }
234
+ } else if (history.reason) {
235
+ // Silence here would leave a user wondering why day one started at zero.
236
+ this.pushError(`history: ${history.reason}`)
237
+ }
238
+
239
+ const result = await this.ownerCtx.session.list({ limit })
240
+ const sessions = Array.isArray(result) ? result : (result?.data ?? [])
241
+ const rows = sessions.map((session: any) => ({
242
+ id: String(session.id),
243
+ title: typeof session.title === "string" ? session.title : undefined,
244
+ model: session.model
245
+ ? { providerID: session.model.providerID, id: session.model.id, ...(session.model.variant ? { variant: session.model.variant } : {}) }
246
+ : undefined,
247
+ cost: typeof session.cost === "number" ? session.cost : 0,
248
+ tokens: session.tokens
249
+ ? {
250
+ input: Number(session.tokens.input) || 0,
251
+ output: Number(session.tokens.output) || 0,
252
+ cache: {
253
+ read: Number(session.tokens.cache?.read) || 0,
254
+ write: Number(session.tokens.cache?.write) || 0,
255
+ },
256
+ }
257
+ : undefined,
258
+ updatedAt: typeof session.time?.updated === "number" ? session.time.updated : Date.now(),
259
+ }))
260
+ this.ledger.bootstrap(rows, {
261
+ days: settings.quota.bootstrapDays,
262
+ limit: settings.quota.bootstrapSessions,
263
+ })
264
+ this.persistSoon()
265
+ } catch (error) {
266
+ this.pushError(`bootstrap: ${safeError(error)}`)
267
+ }
268
+ }
269
+
270
+ private async startQuota() {
271
+ if (this.quotaInitialized || !this.quota) return
272
+ this.quotaInitialized = true
273
+ try {
274
+ await this.quota.initialize()
275
+ } catch (error) {
276
+ this.pushError(`quota: ${safeError(error)}`)
277
+ }
278
+ }
279
+
280
+ claimSession(sessionID: string, bridgeID: string) {
281
+ if (!bridgeID) return
282
+ this.unowned.delete(sessionID)
283
+ if (!this.owners.has(sessionID) && this.owners.size >= 2_000) {
284
+ const oldest = this.owners.keys().next().value
285
+ if (oldest) this.owners.delete(oldest)
286
+ }
287
+ this.owners.set(sessionID, bridgeID)
288
+ }
289
+
290
+ releaseSession(sessionID: string) {
291
+ this.owners.delete(sessionID)
292
+ this.unowned.delete(sessionID)
293
+ }
294
+
295
+ recordError(message: string) {
296
+ this.pushError(message)
297
+ }
298
+
299
+ private noteUnowned(sessionID: string) {
300
+ if (this.unowned.size >= UNOWNED_MAX) {
301
+ const oldest = this.unowned.keys().next().value
302
+ if (oldest) this.unowned.delete(oldest)
303
+ }
304
+ this.unowned.set(sessionID, Date.now())
305
+ }
306
+
307
+ /** One catalog fetch per process; bridges rebuild their own registries from it. */
308
+ async getLiveModels(force = false): Promise<LiveModel[]> {
309
+ const fresh = this.modelsLoadedAt > 0 && Date.now() - this.modelsLoadedAt < MODEL_TTL_MS
310
+ if ((!fresh || force) && !this.modelsLoading && this.ownerCtx?.model?.list) {
311
+ this.modelsLoading = Promise.resolve(this.ownerCtx.model.list())
312
+ .then((result: any) => {
313
+ const data = Array.isArray(result) ? result : (result?.data ?? [])
314
+ this.liveModels = Array.isArray(data) ? (data as LiveModel[]) : []
315
+ this.modelsLoadedAt = Date.now()
316
+ return this.liveModels
317
+ })
318
+ .catch((error) => {
319
+ this.pushError(`models: ${safeError(error)}`)
320
+ return this.liveModels
321
+ })
322
+ .finally(() => {
323
+ this.modelsLoading = undefined
324
+ })
325
+ await this.modelsLoading
326
+ }
327
+ // OpenDesign models come from the provider's own gateway rather than from
328
+ // OpenCode's catalog, so they are merged in here. The merge is one
329
+ // shared, single-flight discovery per process.
330
+ const openDesign = await this.getOpenDesignModels(force)
331
+ return openDesign.length > 0 ? [...this.liveModels, ...openDesign] : this.liveModels
332
+ }
333
+
334
+ /**
335
+ * Read the OpenDesign gateway's entitled model list.
336
+ *
337
+ * This is a read-only metadata call. It makes no inference request and
338
+ * consumes no plan credits. It runs at most once per catalog TTL per
339
+ * process, no matter how many project bridges are open.
340
+ */
341
+ async getOpenDesignModels(force = false): Promise<LiveModel[]> {
342
+ const settings = this.getSettings?.()
343
+ const provider = settings?.providers?.opendesign
344
+ if (!provider?.enabled) {
345
+ this.openDesignModels = []
346
+ this.openDesignLoadedAt = Date.now()
347
+ return []
348
+ }
349
+ const fresh = this.openDesignLoadedAt > 0 && Date.now() - this.openDesignLoadedAt < MODEL_TTL_MS
350
+ if (!force && fresh) return this.openDesignModels
351
+ if (this.openDesignLoading) return this.openDesignLoading
352
+ this.openDesignLoading = (async () => {
353
+ try {
354
+ const credentials = (await this.getCredentials?.()) ?? {}
355
+ if (!credentials.openDesignApiKey) {
356
+ this.openDesignModels = []
357
+ this.openDesignLoadedAt = Date.now()
358
+ return []
359
+ }
360
+ const result = await discoverOpenDesignModels({
361
+ apiKey: credentials.openDesignApiKey,
362
+ ...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
363
+ })
364
+ if (!result.ok) {
365
+ this.pushError(`opendesign discovery: ${result.error ?? "unknown error"}`)
366
+ // Keep the last good catalog rather than dropping every OpenDesign
367
+ // route because one read-only probe failed.
368
+ this.openDesignLoadedAt = Date.now()
369
+ return this.openDesignModels
370
+ }
371
+ this.openDesignModels = toLiveModels(result.models) as LiveModel[]
372
+ this.openDesignLoadedAt = Date.now()
373
+ return this.openDesignModels
374
+ } catch (error) {
375
+ this.pushError(`opendesign discovery: ${safeError(error)}`)
376
+ return this.openDesignModels
377
+ } finally {
378
+ this.openDesignLoading = undefined
379
+ }
380
+ })()
381
+ return this.openDesignLoading
382
+ }
383
+
384
+ /** Local usage events, used by providers whose quota must be modelled locally. */
385
+ usageEvents(window?: { since: number; until?: number }): ReadonlyArray<UsageEvent> {
386
+ return this.ledger.usageEvents(window)
387
+ }
388
+
389
+ ownerOf(sessionID: string): string | undefined {
390
+ return this.owners.get(sessionID)
391
+ }
392
+
393
+ noteEvent(handled: boolean) {
394
+ if (handled) this.eventsHandled += 1
395
+ else this.eventsIgnored += 1
396
+ }
397
+
398
+ addBridge(directory: string, controller?: any): string {
399
+ // A newly loaded location may own sessions previously seen without an owner.
400
+ this.unowned.clear()
401
+ const id = `bridge-${this.pid}-${++this.sequence}`
402
+ this.bridges.set(id, { id, directory, createdAt: Date.now(), controller })
403
+ if (this.shutdownTimer) {
404
+ clearTimeout(this.shutdownTimer)
405
+ this.shutdownTimer = undefined
406
+ }
407
+ this.startEvents()
408
+ return id
409
+ }
410
+
411
+ private startEvents() {
412
+ if (this.eventsStarted || !this.ownerCtx?.event) return
413
+ this.eventsStarted = true
414
+ const abort = new AbortController()
415
+ this.eventAbort = abort
416
+ void (async () => {
417
+ try {
418
+ for await (const event of this.ownerCtx.event.subscribe({ signal: abort.signal })) {
419
+ await this.dispatchEvent(event)
420
+ }
421
+ } catch (error) {
422
+ if (!abort.signal.aborted) this.pushError(`events: ${safeError(error)}`)
423
+ } finally {
424
+ if (this.eventAbort === abort) {
425
+ this.eventAbort = undefined
426
+ this.eventsStarted = false
427
+ }
428
+ }
429
+ })()
430
+ }
431
+
432
+ private async dispatchEvent(event: any) {
433
+ const data = event?.data ?? {}
434
+ const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined
435
+ let target: any
436
+ let targetID: string | undefined
437
+ if (sessionID) {
438
+ targetID = this.owners.get(sessionID)
439
+ if (targetID) target = this.bridges.get(targetID)?.controller
440
+ if (!target && event.type === "session.created" && typeof data.projectID === "string") {
441
+ for (const bridge of this.bridges.values()) {
442
+ if (bridge.controller?.projectMatches?.(data.projectID, data.location?.directory)) {
443
+ target = bridge.controller
444
+ targetID = bridge.id
445
+ break
446
+ }
447
+ }
448
+ if (targetID) this.claimSession(sessionID, targetID)
449
+ }
450
+ if (!target && this.ownerCtx?.session) {
451
+ const seen = this.unowned.get(sessionID)
452
+ if (seen !== undefined && Date.now() - seen < UNOWNED_TTL_MS) {
453
+ this.noteEvent(false)
454
+ return
455
+ }
456
+ try {
457
+ const info = await this.ownerCtx.session.get({ sessionID })
458
+ if (info?.projectID) {
459
+ for (const bridge of this.bridges.values()) {
460
+ if (bridge.controller?.projectMatches?.(info.projectID, info.location?.directory)) {
461
+ target = bridge.controller
462
+ targetID = bridge.id
463
+ this.claimSession(sessionID, bridge.id)
464
+ break
465
+ }
466
+ }
467
+ }
468
+ } catch {
469
+ // Leave unowned events ignored rather than crossing projects.
470
+ }
471
+ if (!target) this.noteUnowned(sessionID)
472
+ }
473
+ }
474
+ if (!target) {
475
+ this.noteEvent(false)
476
+ return
477
+ }
478
+ await target.handleEvent(event)
479
+ }
480
+
481
+ removeBridge(id: string) {
482
+ this.bridges.delete(id)
483
+ this.disposes += 1
484
+ if (this.bridges.size === 0 && this.started) {
485
+ this.shutdownTimer = setTimeout(() => {
486
+ if (this.bridges.size === 0) void this.dispose()
487
+ }, BRIDGE_GRACE_MS)
488
+ }
489
+ }
490
+
491
+ persistSoon() {
492
+ if (this.ledgerTimer) return
493
+ this.ledgerTimer = setTimeout(() => {
494
+ this.ledgerTimer = undefined
495
+ void this.persistNow()
496
+ }, 750)
497
+ }
498
+
499
+ async persistNow() {
500
+ if (!this.ownerCtx?.storage) return
501
+ try {
502
+ const snapshot = safe(this.ledger.export())
503
+ this.ledger.markClean()
504
+ await this.ownerCtx.storage.set(STORAGE_LEDGER, snapshot)
505
+ this.ledgerWrites += 1
506
+ this.lastLedgerWriteAt = Date.now()
507
+ if (this.ledger.dirtyFlag) this.persistSoon()
508
+ } catch (error) {
509
+ this.pushError(`persistLedger: ${safeError(error)}`)
510
+ }
511
+ }
512
+
513
+ persistRuntimeSoon() {
514
+ if (this.runtimeTimer) return
515
+ this.runtimeTimer = setTimeout(() => {
516
+ this.runtimeTimer = undefined
517
+ void this.persistRuntimeNow()
518
+ }, 250)
519
+ }
520
+
521
+ async persistRuntimeNow() {
522
+ if (!this.ownerCtx?.storage) return
523
+ try {
524
+ await this.ownerCtx.storage.set(STORAGE_RUNTIME, safe(this.runtime))
525
+ } catch (error) {
526
+ this.pushError(`persistRuntime: ${safeError(error)}`)
527
+ }
528
+ }
529
+
530
+ persistQuotaSoon() {
531
+ if (this.quotaTimer) return
532
+ this.quotaTimer = setTimeout(() => {
533
+ this.quotaTimer = undefined
534
+ void this.persistQuotaNow()
535
+ }, 1_000)
536
+ }
537
+
538
+ async persistQuotaNow() {
539
+ if (!this.ownerCtx?.storage || !this.quota) return
540
+ try {
541
+ await this.ownerCtx.storage.set(STORAGE_QUOTA, safe(this.quota.read()))
542
+ this.quotaWrites += 1
543
+ this.lastQuotaWriteAt = Date.now()
544
+ } catch (error) {
545
+ this.pushError(`persistQuota: ${safeError(error)}`)
546
+ }
547
+ }
548
+
549
+ async resetLedger() {
550
+ this.ledger.reset()
551
+ this.bootstrapped = false
552
+ this.persistSoon()
553
+ }
554
+
555
+ /** Test-only: clear in-process state between isolated test cases. */
556
+ resetForTests() {
557
+ this.eventAbort?.abort()
558
+ this.eventAbort = undefined
559
+ this.eventsStarted = false
560
+ clearTimeout(this.ledgerTimer)
561
+ clearTimeout(this.quotaTimer)
562
+ clearTimeout(this.runtimeTimer)
563
+ clearTimeout(this.bootstrapTimer)
564
+ clearTimeout(this.quotaStartTimer)
565
+ clearTimeout(this.shutdownTimer)
566
+ this.ledgerTimer = undefined
567
+ this.quotaTimer = undefined
568
+ this.runtimeTimer = undefined
569
+ this.bootstrapTimer = undefined
570
+ this.quotaStartTimer = undefined
571
+ this.shutdownTimer = undefined
572
+ this.ledger = new LedgerStore()
573
+ this.runtime = {}
574
+ this.quota = undefined
575
+ this.ownerCtx = undefined
576
+ this.ready = undefined
577
+ this.loading = undefined
578
+ this.disposing = undefined
579
+ this.started = false
580
+ this.quotaInitialized = false
581
+ this.bootstrapped = false
582
+ this.bridges.clear()
583
+ this.owners.clear()
584
+ this.unowned.clear()
585
+ this.liveModels = []
586
+ this.modelsLoadedAt = 0
587
+ this.modelsLoading = undefined
588
+ this.openDesignModels = []
589
+ this.openDesignLoadedAt = 0
590
+ this.openDesignLoading = undefined
591
+ this.errors.length = 0
592
+ this.eventsHandled = 0
593
+ this.eventsIgnored = 0
594
+ this.sequence = 0
595
+ this.setups = 0
596
+ this.disposes = 0
597
+ this.ledgerWrites = 0
598
+ this.quotaWrites = 0
599
+ this.quotaRefreshes = 0
600
+ }
601
+
602
+ diagnostics(): CoreDiagnostics {
603
+ return {
604
+ coreID: this.coreID,
605
+ pid: this.pid,
606
+ createdAt: this.createdAt,
607
+ uptimeMs: Date.now() - this.createdAt,
608
+ bridges: [...this.bridges.values()].map(({ id, directory, createdAt }) => ({ id, directory, createdAt })),
609
+ bridgeCount: this.bridges.size,
610
+ setups: this.setups,
611
+ disposes: this.disposes,
612
+ ledgerEvents: this.ledger.data.usage.length,
613
+ decisions: this.ledger.data.decisions.length,
614
+ sessions: Object.keys(this.ledger.data.sessions).length,
615
+ quotaSnapshots: this.quota?.read().length ?? 0,
616
+ quotaRefreshes: this.quotaRefreshes,
617
+ ledgerWrites: this.ledgerWrites,
618
+ quotaWrites: this.quotaWrites,
619
+ lastLedgerWriteAt: this.lastLedgerWriteAt,
620
+ lastQuotaWriteAt: this.lastQuotaWriteAt,
621
+ quotaInitialized: this.quotaInitialized,
622
+ bootstrapped: this.bootstrapped,
623
+ eventsHandled: this.eventsHandled,
624
+ eventsIgnored: this.eventsIgnored,
625
+ eventStreams: this.eventsStarted ? 1 : 0,
626
+ errors: this.errors.slice(-20),
627
+ }
628
+ }
629
+
630
+ private dispose(): Promise<void> {
631
+ if (this.disposing) return this.disposing
632
+ const running = this.teardown()
633
+ const tracked = running.finally(() => {
634
+ if (this.disposing === tracked) this.disposing = undefined
635
+ })
636
+ this.disposing = tracked
637
+ return tracked
638
+ }
639
+
640
+ private async teardown() {
641
+ if (this.shutdownTimer) clearTimeout(this.shutdownTimer)
642
+ this.shutdownTimer = undefined
643
+ this.eventAbort?.abort()
644
+ this.eventAbort = undefined
645
+ this.eventsStarted = false
646
+ clearTimeout(this.ledgerTimer)
647
+ clearTimeout(this.quotaTimer)
648
+ clearTimeout(this.runtimeTimer)
649
+ clearTimeout(this.bootstrapTimer)
650
+ clearTimeout(this.quotaStartTimer)
651
+ this.ledgerTimer = undefined
652
+ this.quotaTimer = undefined
653
+ this.runtimeTimer = undefined
654
+ await this.persistNow()
655
+ await this.persistRuntimeNow()
656
+ await this.persistQuotaNow()
657
+ this.started = false
658
+ this.ready = undefined
659
+ this.ownerCtx = undefined
660
+ this.quota = undefined
661
+ this.quotaInitialized = false
662
+ this.quotaInitial = []
663
+ // Keep the core object bound to the process. A later setup revives it via
664
+ // init() (started=false) instead of creating a second core and racing on
665
+ // the same plugin storage.
666
+ }
667
+ }
668
+
669
+ export const sharedCore: SharedCore =
670
+ ((globalThis as any)[KEY] as SharedCore | undefined) ??
671
+ (((globalThis as any)[KEY] = new SharedCore()) as SharedCore)