@cortexkit/opencode-anthropic-auth 1.9.3 → 1.10.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.
@@ -0,0 +1,64 @@
1
+ import {
2
+ mkdir,
3
+ readdir,
4
+ readFile,
5
+ rename,
6
+ unlink,
7
+ writeFile,
8
+ } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ export interface PortFileEntry {
12
+ port: number
13
+ token: string
14
+ pid: number
15
+ startedAt: number
16
+ }
17
+
18
+ function pidAlive(pid: number): boolean {
19
+ try {
20
+ process.kill(pid, 0)
21
+ return true
22
+ } catch (error) {
23
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
24
+ }
25
+ }
26
+
27
+ export async function writePortFile(
28
+ dir: string,
29
+ entry: { port: number; token: string; pid: number },
30
+ ): Promise<string> {
31
+ await mkdir(dir, { recursive: true })
32
+ const full: PortFileEntry = { ...entry, startedAt: Date.now() }
33
+ const target = join(dir, `port-${entry.pid}.json`)
34
+ const tmp = `${target}.${process.pid}.tmp`
35
+ await writeFile(tmp, JSON.stringify(full), { encoding: 'utf8', mode: 0o600 })
36
+ await rename(tmp, target)
37
+ return target
38
+ }
39
+
40
+ export async function discoverPortFile(
41
+ dir: string,
42
+ ): Promise<PortFileEntry | null> {
43
+ let names: string[]
44
+ try {
45
+ names = await readdir(dir)
46
+ } catch {
47
+ return null
48
+ }
49
+ const live: PortFileEntry[] = []
50
+ for (const name of names) {
51
+ if (!name.startsWith('port-') || !name.endsWith('.json')) continue
52
+ try {
53
+ const parsed = JSON.parse(
54
+ await readFile(join(dir, name), 'utf8'),
55
+ ) as PortFileEntry
56
+ if (Number.isFinite(parsed.port)) {
57
+ if (pidAlive(parsed.pid)) live.push(parsed)
58
+ else unlink(join(dir, name)).catch(() => {})
59
+ }
60
+ } catch {}
61
+ }
62
+ if (live.length === 0) return null
63
+ return live.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null
64
+ }
@@ -0,0 +1,31 @@
1
+ export type CommandModalName =
2
+ | 'claude-cache'
3
+ | 'claude-cachekeep'
4
+ | 'claude-quota'
5
+ | 'claude-dump'
6
+ | 'claude-fast'
7
+ | 'claude-routing'
8
+ | 'claude-killswitch'
9
+
10
+ export interface OpenDialogPayload {
11
+ command: CommandModalName
12
+ text: string
13
+ knobs: Record<string, unknown>
14
+ }
15
+
16
+ export interface RpcNotification {
17
+ id: number
18
+ type: 'open-dialog'
19
+ payload: OpenDialogPayload
20
+ sessionId?: string
21
+ }
22
+
23
+ export interface ApplyRequest {
24
+ command: CommandModalName
25
+ arguments: string
26
+ }
27
+
28
+ export interface ApplyResult {
29
+ text: string
30
+ knobs: Record<string, unknown>
31
+ }
@@ -0,0 +1,55 @@
1
+ import { discoverPortFile } from './port-file'
2
+ import type { ApplyRequest, ApplyResult, RpcNotification } from './protocol'
3
+
4
+ export interface RpcClient {
5
+ pending: (
6
+ lastReceivedId: number,
7
+ sessionId?: string,
8
+ ) => Promise<RpcNotification[]>
9
+ apply: (request: ApplyRequest) => Promise<ApplyResult>
10
+ }
11
+
12
+ async function call<T>(
13
+ dir: string,
14
+ method: string,
15
+ params: Record<string, unknown>,
16
+ ): Promise<T | null> {
17
+ const entry = await discoverPortFile(dir)
18
+ if (!entry) return null
19
+ const controller = new AbortController()
20
+ const timer = setTimeout(() => controller.abort(), 2_000)
21
+ try {
22
+ const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, {
23
+ method: 'POST',
24
+ headers: {
25
+ 'content-type': 'application/json',
26
+ authorization: `Bearer ${entry.token}`,
27
+ },
28
+ body: JSON.stringify(params),
29
+ signal: controller.signal,
30
+ })
31
+ if (!res.ok) return null
32
+ return (await res.json()) as T
33
+ } catch {
34
+ return null
35
+ } finally {
36
+ clearTimeout(timer)
37
+ }
38
+ }
39
+
40
+ export function createRpcClient(dir: string): RpcClient {
41
+ return {
42
+ async pending(lastReceivedId, sessionId) {
43
+ const out = await call<{ messages: RpcNotification[] }>(
44
+ dir,
45
+ 'pending-notifications',
46
+ { lastReceivedId, sessionId },
47
+ )
48
+ return out?.messages ?? []
49
+ },
50
+ async apply(request) {
51
+ const out = await call<ApplyResult>(dir, 'apply', { ...request })
52
+ return out ?? { text: 'apply failed', knobs: {} }
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { homedir, tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ const RPC_DIR_ENV = 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR'
6
+
7
+ // Both processes must resolve the SAME dir from the SAME project directory.
8
+ export function getRpcDir(projectDirectory: string): string {
9
+ const override = process.env[RPC_DIR_ENV]?.trim()
10
+ if (override) return override
11
+ const hash = createHash('sha256')
12
+ .update(projectDirectory)
13
+ .digest('hex')
14
+ .slice(0, 16)
15
+ const base = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state')
16
+ return join(base, 'cortexkit', 'anthropic-auth', 'rpc', hash)
17
+ }
18
+
19
+ export { tmpdir }
@@ -0,0 +1,121 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { unlink } from 'node:fs/promises'
3
+ import {
4
+ createServer,
5
+ type IncomingMessage,
6
+ type ServerResponse,
7
+ } from 'node:http'
8
+ import { join } from 'node:path'
9
+ import type { drainNotifications } from './notifications'
10
+ import { writePortFile } from './port-file'
11
+ import type { ApplyRequest, ApplyResult } from './protocol'
12
+
13
+ export interface RpcServerHandle {
14
+ port: number
15
+ token: string
16
+ stop: () => Promise<void>
17
+ }
18
+
19
+ export interface RpcServerOptions {
20
+ dir: string
21
+ drain: typeof drainNotifications
22
+ apply: (request: ApplyRequest) => Promise<ApplyResult>
23
+ }
24
+
25
+ function readBody(req: IncomingMessage): Promise<string> {
26
+ return new Promise((resolve, reject) => {
27
+ const chunks: Buffer[] = []
28
+ let size = 0
29
+ req.on('data', (chunk: Buffer) => {
30
+ size += chunk.length
31
+ if (size > 1_000_000) {
32
+ req.destroy()
33
+ reject(new Error('body too large'))
34
+ return
35
+ }
36
+ chunks.push(chunk)
37
+ })
38
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
39
+ req.on('error', reject)
40
+ })
41
+ }
42
+
43
+ function tokenOk(header: string | undefined, token: string): boolean {
44
+ if (!header?.startsWith('Bearer ')) return false
45
+ const got = Buffer.from(header.slice(7))
46
+ const want = Buffer.from(token)
47
+ return got.length === want.length && timingSafeEqual(got, want)
48
+ }
49
+
50
+ export async function startRpcServer(
51
+ options: RpcServerOptions,
52
+ ): Promise<RpcServerHandle> {
53
+ const token = randomBytes(32).toString('hex')
54
+ const server = createServer((req, res) => {
55
+ void dispatch(req, res)
56
+ })
57
+
58
+ async function dispatch(req: IncomingMessage, res: ServerResponse) {
59
+ const json = (status: number, value: unknown) => {
60
+ res.writeHead(status, { 'content-type': 'application/json' })
61
+ res.end(JSON.stringify(value))
62
+ }
63
+ try {
64
+ const url = req.url ?? ''
65
+ if (req.method === 'GET' && url === '/health')
66
+ return json(200, { ok: true })
67
+ if (req.method !== 'POST' || !url.startsWith('/rpc/'))
68
+ return json(404, { error: 'not found' })
69
+ if (!tokenOk(req.headers.authorization, token))
70
+ return json(401, { error: 'unauthorized' })
71
+ const method = url.slice('/rpc/'.length)
72
+ const params = JSON.parse((await readBody(req)) || '{}') as Record<
73
+ string,
74
+ unknown
75
+ >
76
+ if (method === 'pending-notifications') {
77
+ const messages = options.drain(
78
+ Number(params.lastReceivedId ?? 0),
79
+ typeof params.sessionId === 'string' ? params.sessionId : undefined,
80
+ )
81
+ return json(200, { messages })
82
+ }
83
+ if (method === 'apply') {
84
+ const result = await options.apply(params as unknown as ApplyRequest)
85
+ return json(200, result)
86
+ }
87
+ return json(404, { error: 'unknown method' })
88
+ } catch (error) {
89
+ json(500, {
90
+ error: error instanceof Error ? error.message : String(error),
91
+ })
92
+ }
93
+ }
94
+
95
+ const port = await new Promise<number>((resolve, reject) => {
96
+ server.on('error', reject)
97
+ server.listen(0, '127.0.0.1', () => {
98
+ const addr = server.address()
99
+ if (addr && typeof addr === 'object') resolve(addr.port)
100
+ else reject(new Error('no port'))
101
+ })
102
+ })
103
+ server.unref()
104
+ try {
105
+ await writePortFile(options.dir, { port, token, pid: process.pid })
106
+ } catch (error) {
107
+ await new Promise<void>((resolve) => server.close(() => resolve()))
108
+ throw error
109
+ }
110
+
111
+ return {
112
+ port,
113
+ token,
114
+ async stop() {
115
+ await new Promise<void>((resolve) => server.close(() => resolve()))
116
+ await unlink(join(options.dir, `port-${process.pid}.json`)).catch(
117
+ () => {},
118
+ )
119
+ },
120
+ }
121
+ }
@@ -0,0 +1,229 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginApi } from '@opencode-ai/plugin/tui'
3
+ import type { OpenDialogPayload } from '../rpc/protocol.js'
4
+
5
+ type ApplyFn = (
6
+ command: OpenDialogPayload['command'],
7
+ args: string,
8
+ ) => Promise<{ text: string }>
9
+
10
+ function showText(api: TuiPluginApi, text: string) {
11
+ api.ui.dialog.setSize('xlarge')
12
+ api.ui.dialog.replace(() => (
13
+ <box flexDirection='column' padding={1} width='100%'>
14
+ <text>{text}</text>
15
+ </box>
16
+ ))
17
+ }
18
+
19
+ export function openCommandDialog(
20
+ api: TuiPluginApi,
21
+ payload: OpenDialogPayload,
22
+ apply: ApplyFn,
23
+ ) {
24
+ if (payload.command === 'claude-routing') {
25
+ const current = (payload.knobs.mode as string) ?? 'main-first'
26
+ const DialogSelect = api.ui.DialogSelect<string>
27
+ api.ui.dialog.setSize('xlarge')
28
+ api.ui.dialog.replace(() => (
29
+ <DialogSelect
30
+ title='Claude routing'
31
+ current={current}
32
+ options={[
33
+ {
34
+ title: 'Main first',
35
+ value: 'main-first',
36
+ description: 'Use the main account until exhausted',
37
+ },
38
+ {
39
+ title: 'Fallback first',
40
+ value: 'fallback-first',
41
+ description: 'Prefer fallback accounts, preserve main',
42
+ },
43
+ ]}
44
+ onSelect={(option) => {
45
+ void apply('claude-routing', String(option.value)).then((r) => {
46
+ api.ui.toast({ message: r.text })
47
+ api.ui.dialog.clear()
48
+ })
49
+ }}
50
+ />
51
+ ))
52
+ return
53
+ }
54
+
55
+ if (payload.command === 'claude-fast' || payload.command === 'claude-dump') {
56
+ const enabled = payload.knobs.enabled === true
57
+ const label =
58
+ payload.command === 'claude-fast' ? 'fast mode' : 'request dump'
59
+ const DialogConfirm = api.ui.DialogConfirm
60
+ api.ui.dialog.setSize('xlarge')
61
+ api.ui.dialog.replace(() => (
62
+ <DialogConfirm
63
+ title={`Claude ${label}`}
64
+ message={`${payload.text}\n\n${enabled ? 'Disable' : 'Enable'} ${label}?`}
65
+ onConfirm={() => {
66
+ void apply(payload.command, enabled ? 'off' : 'on').then((r) => {
67
+ api.ui.toast({ message: r.text })
68
+ api.ui.dialog.clear()
69
+ })
70
+ }}
71
+ onCancel={() => api.ui.dialog.clear()}
72
+ />
73
+ ))
74
+ return
75
+ }
76
+
77
+ if (payload.command === 'claude-cache') {
78
+ const enabled = payload.knobs.enabled === true
79
+ const mode = (payload.knobs.mode as string) ?? 'hybrid'
80
+ const currentValue = enabled ? mode : 'off'
81
+ const DialogSelect = api.ui.DialogSelect<string>
82
+ api.ui.dialog.setSize('xlarge')
83
+ api.ui.dialog.replace(() => (
84
+ <DialogSelect
85
+ title='Claude 1h cache'
86
+ current={currentValue}
87
+ options={[
88
+ { title: 'Off', value: 'off', description: 'Disable 1h cache' },
89
+ {
90
+ title: 'Explicit',
91
+ value: 'explicit',
92
+ description: 'Existing OpenCode breakpoints',
93
+ },
94
+ {
95
+ title: 'Automatic',
96
+ value: 'automatic',
97
+ description: 'Top-level cache_control only',
98
+ },
99
+ {
100
+ title: 'Hybrid',
101
+ value: 'hybrid',
102
+ description: 'system + messages[0] + top-level',
103
+ },
104
+ ]}
105
+ onSelect={(option) => {
106
+ if (option.value === 'off') {
107
+ void apply('claude-cache', 'off').then((r) => {
108
+ api.ui.toast({ message: r.text })
109
+ api.ui.dialog.clear()
110
+ })
111
+ return
112
+ }
113
+ void apply('claude-cache', `mode ${option.value}`)
114
+ .then(() => apply('claude-cache', 'on'))
115
+ .then((r) => {
116
+ api.ui.toast({ message: r.text })
117
+ api.ui.dialog.clear()
118
+ })
119
+ }}
120
+ />
121
+ ))
122
+ return
123
+ }
124
+
125
+ if (payload.command === 'claude-cachekeep') {
126
+ const window = payload.knobs.window as
127
+ | { startHour: number; endHour: number }
128
+ | undefined
129
+ const seed = window
130
+ ? `${String(window.startHour).padStart(2, '0')}-${String(window.endHour).padStart(2, '0')}`
131
+ : ''
132
+ const DialogPrompt = api.ui.DialogPrompt
133
+ api.ui.dialog.setSize('xlarge')
134
+ api.ui.dialog.replace(() => (
135
+ <DialogPrompt
136
+ title='Claude cachekeep window'
137
+ description={() => <text>{payload.text}</text>}
138
+ placeholder="HH-HH (e.g. 08-20) or 'off'"
139
+ value={seed}
140
+ onConfirm={(value: string) => {
141
+ void apply('claude-cachekeep', value.trim()).then((r) => {
142
+ api.ui.toast({ message: r.text })
143
+ api.ui.dialog.clear()
144
+ })
145
+ }}
146
+ onCancel={() => api.ui.dialog.clear()}
147
+ />
148
+ ))
149
+ return
150
+ }
151
+
152
+ if (payload.command === 'claude-killswitch') {
153
+ const config = (payload.knobs.config ?? {}) as {
154
+ enabled?: boolean
155
+ main?: Record<string, number>
156
+ accounts?: Record<string, Record<string, number>>
157
+ }
158
+ const accountIds = (payload.knobs.accountIds as string[]) ?? []
159
+ const enabled = config.enabled === true
160
+ const readT = (t: Record<string, number> | undefined) => {
161
+ const fh = t?.five_hour ?? t?.['5h'] ?? 5
162
+ const sd = t?.seven_day ?? t?.['1w'] ?? 10
163
+ return { fh, sd }
164
+ }
165
+ const mainT = readT(config.main)
166
+ const seedParts = [`main:${mainT.fh},${mainT.sd}`]
167
+ for (const id of accountIds) {
168
+ const t = readT(config.accounts?.[id] ?? config.main)
169
+ seedParts.push(`${id}:${t.fh},${t.sd}`)
170
+ }
171
+ const seed = seedParts.join(' ')
172
+
173
+ const openEdit = () => {
174
+ const DialogPrompt = api.ui.DialogPrompt
175
+ api.ui.dialog.setSize('xlarge')
176
+ api.ui.dialog.replace(() => (
177
+ <DialogPrompt
178
+ title='Killswitch thresholds'
179
+ description={() => <text>{payload.text}</text>}
180
+ placeholder='main:5,10 work-alt:5,10'
181
+ value={seed}
182
+ onConfirm={(value: string) => {
183
+ void apply('claude-killswitch', `set ${value.trim()}`).then((r) => {
184
+ api.ui.toast({ message: r.text })
185
+ api.ui.dialog.clear()
186
+ })
187
+ }}
188
+ onCancel={() => api.ui.dialog.clear()}
189
+ />
190
+ ))
191
+ }
192
+
193
+ const DialogSelect = api.ui.DialogSelect<string>
194
+ api.ui.dialog.setSize('xlarge')
195
+ api.ui.dialog.replace(() => (
196
+ <DialogSelect
197
+ title='Claude killswitch'
198
+ options={[
199
+ {
200
+ title: enabled ? 'Disable killswitch' : 'Enable killswitch',
201
+ value: enabled ? 'off' : 'on',
202
+ description: enabled
203
+ ? 'Stop hard-blocking on low quota'
204
+ : 'Hard-block requests when quota drops below thresholds',
205
+ },
206
+ {
207
+ title: 'Edit thresholds…',
208
+ value: 'edit',
209
+ description: 'Set per-account 5h,1w cutoffs',
210
+ },
211
+ ]}
212
+ onSelect={(option) => {
213
+ if (option.value === 'edit') {
214
+ openEdit()
215
+ return
216
+ }
217
+ void apply('claude-killswitch', String(option.value)).then((r) => {
218
+ api.ui.toast({ message: r.text })
219
+ api.ui.dialog.clear()
220
+ })
221
+ }}
222
+ />
223
+ ))
224
+ return
225
+ }
226
+
227
+ // fallback for quota (display-only)
228
+ showText(api, payload.text)
229
+ }
package/src/tui.tsx CHANGED
@@ -16,7 +16,8 @@ import {
16
16
  onCleanup,
17
17
  Show,
18
18
  } from 'solid-js'
19
-
19
+ import { createRpcClient } from './rpc/rpc-client.js'
20
+ import { getRpcDir } from './rpc/rpc-dir.js'
20
21
  import {
21
22
  type AccountQuota,
22
23
  computeQuotaPacing,
@@ -29,6 +30,7 @@ import {
29
30
  SEVEN_DAY_MS,
30
31
  type SidebarState,
31
32
  } from './sidebar-state.js'
33
+ import { openCommandDialog } from './tui/command-dialogs.js'
32
34
  import {
33
35
  type AnthropicAuthTuiPrefs,
34
36
  type AppearancePrefs,
@@ -41,6 +43,9 @@ import {
41
43
  watchTuiPreferences,
42
44
  } from './tui-preferences.js'
43
45
 
46
+ const RPC_POLL_MS = 500
47
+ let rpcPollStarted = false
48
+
44
49
  const ID = 'cortexkit.anthropic-auth'
45
50
 
46
51
  // Plugin version for the header (mirrors the Magic Context / AFT convention).
@@ -345,6 +350,67 @@ function AccountBlock(props: {
345
350
  )
346
351
  }
347
352
 
353
+ // --- Quota dialog content ---------------------------------------------------
354
+
355
+ // Renders the same rich visualization as the sidebar (account blocks with
356
+ // quota bars + pacing) so the modal matches the sidebar look.
357
+ function QuotaDialogContent(props: {
358
+ api: TuiPluginApi
359
+ controller: SidebarController
360
+ }) {
361
+ const prefs = props.controller.prefs
362
+ const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
363
+ let lastUpdated = 0
364
+ async function refresh() {
365
+ const next = await readStateFromFile()
366
+ if (next.lastUpdated !== lastUpdated) {
367
+ lastUpdated = next.lastUpdated
368
+ setState(next)
369
+ }
370
+ }
371
+ createEffect(() => {
372
+ const timer = setInterval(refresh, prefs().pollMs)
373
+ onCleanup(() => clearInterval(timer))
374
+ })
375
+ setTimeout(refresh, 0)
376
+ const theme = () => props.api.theme.current
377
+ const enabledFallbacks = () => state().fallbacks.filter((f) => f.enabled)
378
+ return (
379
+ <box flexDirection='column' padding={2} width='100%' alignItems='center'>
380
+ <box flexDirection='column' width={58}>
381
+ <box width='100%' justifyContent='center' marginBottom={1}>
382
+ <text fg={theme().text}>
383
+ <b>Quota</b>
384
+ </text>
385
+ </box>
386
+ <AccountBlock
387
+ theme={theme()}
388
+ appearance={prefs().appearance}
389
+ name='main'
390
+ quota={state().main.quota}
391
+ active={state().activeId === 'main'}
392
+ pacingEnabled={prefs().sections.pacing}
393
+ />
394
+ <Show when={prefs().sections.fallbackAccounts}>
395
+ <For each={enabledFallbacks()}>
396
+ {(fb) => (
397
+ <AccountBlock
398
+ theme={theme()}
399
+ appearance={prefs().appearance}
400
+ name={fb.label ?? fb.id}
401
+ quota={fb.quota}
402
+ active={state().activeId === fb.id}
403
+ pacingEnabled={prefs().sections.pacing}
404
+ marginTop={1}
405
+ />
406
+ )}
407
+ </For>
408
+ </Show>
409
+ </box>
410
+ </box>
411
+ )
412
+ }
413
+
348
414
  // --- State plumbing ---------------------------------------------------------
349
415
 
350
416
  async function readStateFromFile(): Promise<SidebarState> {
@@ -701,6 +767,44 @@ const tui: TuiPlugin = async (api) => {
701
767
  },
702
768
  },
703
769
  })
770
+
771
+ if (!rpcPollStarted) {
772
+ rpcPollStarted = true
773
+ const rpcClient = createRpcClient(getRpcDir(api.state.path.directory ?? ''))
774
+ let lastNotificationId = 0
775
+ let rpcInFlight = false
776
+ setInterval(() => {
777
+ if (rpcInFlight) return
778
+ const current = (api.route as { current?: unknown }).current
779
+ const resolved =
780
+ typeof current === 'function' ? (current as () => unknown)() : current
781
+ const sessionId = (
782
+ resolved as { params?: { sessionID?: string } } | undefined
783
+ )?.params?.sessionID
784
+ rpcInFlight = true
785
+ void rpcClient
786
+ .pending(lastNotificationId, sessionId)
787
+ .then((messages) => {
788
+ for (const message of [...messages].sort((a, b) => a.id - b.id)) {
789
+ lastNotificationId = Math.max(lastNotificationId, message.id)
790
+ if (message.payload.command === 'claude-quota') {
791
+ api.ui.dialog.setSize('xlarge')
792
+ api.ui.dialog.replace(() => (
793
+ <QuotaDialogContent api={api} controller={controller} />
794
+ ))
795
+ continue
796
+ }
797
+ openCommandDialog(api, message.payload, (command, args) =>
798
+ rpcClient.apply({ command, arguments: args }),
799
+ )
800
+ }
801
+ })
802
+ .catch(() => {})
803
+ .finally(() => {
804
+ rpcInFlight = false
805
+ })
806
+ }, RPC_POLL_MS)
807
+ }
704
808
  }
705
809
 
706
810
  const plugin: TuiPluginModule & { id: string } = {