@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.
- package/dist/cli.js +1 -1
- package/dist/index-3ynbghx3.js +1 -0
- package/dist/index-fvzta6xw.js +410 -0
- package/dist/index-jb14nj5c.js +2 -0
- package/dist/{index-agh52sak.js → index-p8j7mmfc.js} +1 -1
- package/dist/index.js +10 -9
- package/dist/rpc/notifications.d.ts +5 -0
- package/dist/rpc/port-file.d.ts +12 -0
- package/dist/rpc/port-file.js +1 -0
- package/dist/rpc/protocol.d.ts +20 -0
- package/dist/rpc/protocol.js +0 -0
- package/dist/rpc/rpc-client.d.ts +6 -0
- package/dist/rpc/rpc-client.js +1 -0
- package/dist/rpc/rpc-dir.d.ts +3 -0
- package/dist/rpc/rpc-dir.js +1 -0
- package/dist/rpc/rpc-server.d.ts +13 -0
- package/dist/sidebar-state.js +1 -1
- package/dist/tui/command-dialogs.d.ts +8 -0
- package/dist/tui-preferences.js +1 -1
- package/package.json +5 -3
- package/src/rpc/notifications.ts +54 -0
- package/src/rpc/port-file.ts +64 -0
- package/src/rpc/protocol.ts +31 -0
- package/src/rpc/rpc-client.ts +55 -0
- package/src/rpc/rpc-dir.ts +19 -0
- package/src/rpc/rpc-server.ts +121 -0
- package/src/tui/command-dialogs.tsx +229 -0
- package/src/tui.tsx +105 -1
- package/dist/index-68qrf9fn.js +0 -410
|
@@ -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 } = {
|