@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1

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