pantheon-opencode 1.2.0 → 1.2.2
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/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -2,13 +2,59 @@
|
|
|
2
2
|
// biome-ignore-all lint/a11y/noStaticElementInteractions: TUI elements, not DOM
|
|
3
3
|
/** @jsxImportSource @opentui/solid */
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* pantheon-tui — Pantheon TUI plugin for opencode.
|
|
7
|
+
*
|
|
8
|
+
* Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
|
|
9
|
+
* keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
|
|
10
|
+
* on the fly by opencode). All feature code lives in this one file.
|
|
11
|
+
*
|
|
12
|
+
* Slots:
|
|
13
|
+
* - sidebar_content (order 900) — Pantheon sidebar (header/version/
|
|
14
|
+
* branch, Sessions, Commands, Agents, Config, Memory).
|
|
15
|
+
* - app_bottom (order 60) — AI subscription usage gauges
|
|
16
|
+
* (Anthropic/OpenAI quotas, OpenCode Go/Zen dollar limits + provider
|
|
17
|
+
* status incidents).
|
|
18
|
+
*
|
|
19
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
* VENDORED FEATURES (MIT) — incorporated with their license headers preserved:
|
|
21
|
+
*
|
|
22
|
+
* • satas20/opencode-usage-bar (MIT)
|
|
23
|
+
* https://github.com/satas20/opencode-usage-bar
|
|
24
|
+
*
|
|
25
|
+
* v1.2.0 added an OpenCode Go/Zen usage provider (`opencodego`) on top of
|
|
26
|
+
* the vendored base — dollar-denominated rolling limits ($12/5h, $30/7d,
|
|
27
|
+
* $60/month) via the same key used for inference. Still MIT-credited to
|
|
28
|
+
* satas20 for the original bar/config/poll code.
|
|
29
|
+
*
|
|
30
|
+
* satas20/opencode-todo-progress was vendored in v1.1.0 alongside the
|
|
31
|
+
* usage-bar but has since been REMOVED as redundant — the native session
|
|
32
|
+
* footer already surfaces todo/context state, so its todo bar slot was
|
|
33
|
+
* dropped entirely. Only usage-bar code remains.
|
|
34
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* LIMITATION — native context/tokens statusline:
|
|
37
|
+
* The opencode session footer natively shows a context/tokens statusline
|
|
38
|
+
* next to the prompt. There is NO config option to hide it: the tui.json
|
|
39
|
+
* schema (opencode.ai/tui.json) only exposes `theme`, `keybinds`,
|
|
40
|
+
* `scroll_speed`, `scroll_acceleration`, `diff_style`, `mouse` and
|
|
41
|
+
* `attention`, and the SDK `Config` type has no statusline/tokens display
|
|
42
|
+
* flag (checked against @opencode-ai/sdk `Config` and the published
|
|
43
|
+
* schema). The Pantheon sidebar therefore no longer duplicates that footer
|
|
44
|
+
* (the old ContextBar was removed) and we deliberately do NOT hack the
|
|
45
|
+
* native footer. Track upstream: opencode statusline config.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { Buffer } from 'node:buffer'
|
|
49
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
50
|
+
import { homedir } from 'node:os'
|
|
51
|
+
import { dirname, join } from 'node:path'
|
|
52
|
+
import { fileURLToPath } from 'node:url'
|
|
5
53
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
|
|
6
54
|
import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
|
7
55
|
|
|
8
56
|
/* ─── Constants ─────────────────────────────────────────── */
|
|
9
57
|
|
|
10
|
-
const BAR_WIDTH = 20
|
|
11
|
-
|
|
12
58
|
const COMMANDS = [
|
|
13
59
|
{ name: '/pantheon', desc: 'Council synthesis' },
|
|
14
60
|
{ name: '/pantheon-status', desc: 'System status' },
|
|
@@ -23,6 +69,10 @@ const COMMANDS = [
|
|
|
23
69
|
{ name: '/pantheon-forget', desc: 'Compress memories' },
|
|
24
70
|
] as const
|
|
25
71
|
|
|
72
|
+
/** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
|
|
73
|
+
* changes made while opencode is open show up within ~30s. */
|
|
74
|
+
const PRESET_REFRESH_MS = 30_000
|
|
75
|
+
|
|
26
76
|
const AGENTS = [
|
|
27
77
|
{ name: 'zeus', tier: 'default' as const, role: 'Orchestrator' },
|
|
28
78
|
{ name: 'athena', tier: 'premium' as const, role: 'Strategic planner' },
|
|
@@ -42,37 +92,68 @@ const AGENTS = [
|
|
|
42
92
|
|
|
43
93
|
/* ─── Helpers ───────────────────────────────────────────── */
|
|
44
94
|
|
|
45
|
-
function safeNum(v: unknown): number {
|
|
46
|
-
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
47
|
-
}
|
|
48
|
-
|
|
49
95
|
function fmtInt(n: number): string {
|
|
50
96
|
return Intl.NumberFormat('en-US').format(Math.max(0, Math.round(n)))
|
|
51
97
|
}
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
99
|
+
/* ─── Active Preset Detection ──────────────────────────────
|
|
100
|
+
* Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
|
|
101
|
+
* plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
|
|
102
|
+
* relative imports), so the resolution logic is mirrored here rather than
|
|
103
|
+
* importing presets.mjs.
|
|
104
|
+
*
|
|
105
|
+
* Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
|
|
106
|
+
* Custom/unknown preset names are shown as-is (a preset may be defined that
|
|
107
|
+
* is not in the built-in list). "none" disables → default. */
|
|
108
|
+
|
|
109
|
+
type PresetInfo = { name: string | null; source: 'env' | 'file' | null }
|
|
58
110
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
111
|
+
/** Read .pantheon/active-preset.json — mirrors the presets.mjs file leg:
|
|
112
|
+
* first existing candidate wins; malformed JSON or a `preset` that is
|
|
113
|
+
* missing, empty or "none" → null (no fall-through to lower candidates). */
|
|
114
|
+
async function readActivePresetFile(cwd: string): Promise<{ name: string; source: 'file' } | null> {
|
|
115
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
|
|
116
|
+
const candidates = [
|
|
117
|
+
join(cwd, '.pantheon', 'active-preset.json'),
|
|
118
|
+
join(xdgConfig, 'opencode', '.pantheon', 'active-preset.json'),
|
|
119
|
+
join(homedir(), '.opencode', '.pantheon', 'active-preset.json'),
|
|
120
|
+
]
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
let raw: string
|
|
123
|
+
try {
|
|
124
|
+
raw = await readFile(candidate, 'utf8')
|
|
125
|
+
} catch {
|
|
126
|
+
continue // candidate does not exist — try the next one
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(raw) as { preset?: unknown }
|
|
130
|
+
const name = parsed && typeof parsed === 'object' ? parsed.preset : undefined
|
|
131
|
+
if (typeof name !== 'string' || name.length === 0 || name === 'none') return null
|
|
132
|
+
return { name, source: 'file' }
|
|
133
|
+
} catch {
|
|
134
|
+
return null // malformed file — treat as default (mirrors presets.mjs)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
63
138
|
}
|
|
64
139
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
/** Env leg of resolution: PANTHEON_MODEL_PRESET set and !== 'none' wins. */
|
|
141
|
+
function presetFromEnv(env: Record<string, string | undefined>): PresetInfo {
|
|
142
|
+
const name = env.PANTHEON_MODEL_PRESET
|
|
143
|
+
if (name !== undefined && name !== '' && name !== 'none') {
|
|
144
|
+
return { name, source: 'env' }
|
|
68
145
|
}
|
|
69
|
-
return
|
|
146
|
+
return { name: null, source: null }
|
|
70
147
|
}
|
|
71
148
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
149
|
+
/** Resolve the active preset for the sidebar: env > file > default. */
|
|
150
|
+
async function resolvePresetForTui(
|
|
151
|
+
env: Record<string, string | undefined>,
|
|
152
|
+
cwd: string,
|
|
153
|
+
): Promise<PresetInfo> {
|
|
154
|
+
const envPreset = presetFromEnv(env)
|
|
155
|
+
if (envPreset.source === 'env') return envPreset
|
|
156
|
+
return (await readActivePresetFile(cwd)) ?? { name: null, source: null }
|
|
76
157
|
}
|
|
77
158
|
|
|
78
159
|
/* ─── Version Detection ────────────────────────────────────
|
|
@@ -86,25 +167,51 @@ function buildBar(pct: number): string {
|
|
|
86
167
|
* 4. null */
|
|
87
168
|
|
|
88
169
|
async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
170
|
+
// Try 0: package.json do PACOTE PANTHEON instalado. O TUI é carregado de
|
|
171
|
+
// <pacote>/src/plugins/tui/dist/tui.tsx (fix #32 garante esse path) →
|
|
172
|
+
// 4 níveis acima (dist → tui → plugins → src → raiz) está o package.json
|
|
173
|
+
// do pacote. Funciona em QUALQUER cwd (ex: sandbox sem package.json/git) e
|
|
174
|
+
// também em dev (source no repo). Se falhar, cai para os tries abaixo.
|
|
175
|
+
try {
|
|
176
|
+
const pkgUrl = new URL('../../../../package.json', import.meta.url)
|
|
177
|
+
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
178
|
+
const match = pkgContent.match(/"version":\s*"([^"]+)"/)
|
|
179
|
+
if (match?.[1]) return match[1]
|
|
180
|
+
} catch {
|
|
181
|
+
/* fall through */
|
|
182
|
+
}
|
|
183
|
+
|
|
89
184
|
// Try 1: package.json do projeto (via worktree path)
|
|
90
185
|
try {
|
|
91
186
|
const wt = ((api.state as any).path?.worktree ?? '') as string
|
|
92
187
|
const fp = wt ? `${wt}/package.json` : 'package.json'
|
|
93
|
-
const result = await api.client.file.read({
|
|
94
|
-
const
|
|
188
|
+
const result = await api.client.file.read({ path: fp })
|
|
189
|
+
const content = String(result?.data?.content ?? '')
|
|
190
|
+
const match = content.match(/"version":\s*"([^"]+)"/)
|
|
95
191
|
if (match?.[1]) return match[1]
|
|
96
|
-
} catch {
|
|
192
|
+
} catch {
|
|
193
|
+
/* fall through */
|
|
194
|
+
}
|
|
97
195
|
|
|
98
196
|
// Try 2: git describe --tags
|
|
99
197
|
try {
|
|
100
198
|
const proc = (api as any).client?.process
|
|
101
199
|
if (typeof proc?.exec === 'function') {
|
|
102
|
-
const r = await proc.exec({
|
|
200
|
+
const r = await proc.exec({
|
|
201
|
+
command: 'git',
|
|
202
|
+
args: ['describe', '--tags', '--always'],
|
|
203
|
+
timeoutMs: 3000,
|
|
204
|
+
})
|
|
103
205
|
const stdout = (r.stdout ?? r.output ?? '') as string
|
|
104
|
-
const tag = stdout
|
|
206
|
+
const tag = stdout
|
|
207
|
+
.trim()
|
|
208
|
+
.replace(/^v/, '')
|
|
209
|
+
.replace(/-\d+-g[0-9a-f]+$/, '')
|
|
105
210
|
if (tag && tag !== '5.0.0') return tag
|
|
106
211
|
}
|
|
107
|
-
} catch {
|
|
212
|
+
} catch {
|
|
213
|
+
/* fall through */
|
|
214
|
+
}
|
|
108
215
|
|
|
109
216
|
// Try 3: opencode --version
|
|
110
217
|
try {
|
|
@@ -115,71 +222,655 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
|
115
222
|
const ver = stdout.trim().replace(/^v/, '')
|
|
116
223
|
if (ver) return ver
|
|
117
224
|
}
|
|
118
|
-
} catch {
|
|
225
|
+
} catch {
|
|
226
|
+
/* fall through */
|
|
227
|
+
}
|
|
119
228
|
|
|
120
229
|
return null // sem fallback — versão não aparece
|
|
121
230
|
}
|
|
122
231
|
|
|
123
|
-
/*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
232
|
+
/* ──────────────────────────────────────────────────────────
|
|
233
|
+
* VENDORED: opencode-usage-bar (MIT) — satas20
|
|
234
|
+
* https://github.com/satas20/opencode-usage-bar
|
|
235
|
+
* ────────────────────────────────────────────────────────── */
|
|
126
236
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
237
|
+
/**
|
|
238
|
+
* usage-bar — AI subscription usage gauge for the opencode TUI.
|
|
239
|
+
*
|
|
240
|
+
* Renders a compact usage strip in the `app_bottom` slot — a full-width row
|
|
241
|
+
* just below the session footer:
|
|
242
|
+
*
|
|
243
|
+
* ▓▓▓▓░░ 65% · 0h 11m (one window)
|
|
244
|
+
* cld ▓▓▓▓░ 65% · 0h 11m 7d ▓░░░░ 19% · 1d 11h oai ▓░░░░ 12% · 3h 4m
|
|
245
|
+
* ! cld ▓▓▓▓░ 65% · 0h 11m (anthropic incident)
|
|
246
|
+
*
|
|
247
|
+
* Providers:
|
|
248
|
+
* anthropic — Claude Pro/Max via the OAuth token in ~/.claude/.credentials.json
|
|
249
|
+
* openai — ChatGPT Plus/Pro via the Codex CLI login in ~/.codex/auth.json
|
|
250
|
+
* opencodego— OpenCode Go/Zen dollar limits ($12/5h, $30/7d, $60/mo) via
|
|
251
|
+
* PANTHEON_OPENCODE_API_KEY (fallback OPENCODE_API_KEY). No
|
|
252
|
+
* status page — the provider hides silently when unconfigured
|
|
253
|
+
* or when the (not yet public) usage endpoint is unavailable.
|
|
254
|
+
*
|
|
255
|
+
* When a provider's public status page reports an incident, a colored `!`
|
|
256
|
+
* marker appears next to its prefix (red = major/critical, amber = minor,
|
|
257
|
+
* cyan = maintenance). The usage bar itself is unaffected. Disable with
|
|
258
|
+
* `show_status = false` under `[ui]`.
|
|
259
|
+
*
|
|
260
|
+
* Configured via ~/.config/opencode/usage-bar.toml (auto-created with
|
|
261
|
+
* commented defaults on first run; read once at startup). Tokens/keys are
|
|
262
|
+
* only ever sent to their own provider's API host.
|
|
263
|
+
*
|
|
264
|
+
* Loaded via tui.json, e.g.:
|
|
265
|
+
* { "plugin": ["@satas/opencode-usage-bar"] } // published npm package
|
|
266
|
+
* { "plugin": ["/abs/path/to/src/index.tsx"] } // local file (no build)
|
|
267
|
+
*/
|
|
132
268
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
269
|
+
const POLL_MS = 120_000
|
|
270
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
271
|
+
const CONFIG_FILE = 'usage-bar.toml'
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Types
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/** Which quota window a value belongs to; toggled per provider in config. */
|
|
278
|
+
type WindowCategory = '5h' | '7d' | '1m' | 'model'
|
|
279
|
+
|
|
280
|
+
/** Provider health from the vendor's public status page (Statuspage schema). */
|
|
281
|
+
type StatusIndicator = 'none' | 'minor' | 'major' | 'critical' | 'maintenance'
|
|
282
|
+
|
|
283
|
+
type UsageWindow = {
|
|
284
|
+
category: WindowCategory
|
|
285
|
+
/** short display label, e.g. "5h", "7d", "Fable" */
|
|
286
|
+
label: string
|
|
287
|
+
/** 0–100 percent of the window used */
|
|
288
|
+
percent: number
|
|
289
|
+
/** epoch ms when the window resets */
|
|
290
|
+
resetsAt: number
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type ProviderId = 'anthropic' | 'openai' | 'opencodego'
|
|
294
|
+
|
|
295
|
+
type ProviderConfig = {
|
|
296
|
+
enabled: boolean
|
|
297
|
+
show: Record<WindowCategory, boolean>
|
|
298
|
+
/** anthropic: path to .credentials.json */
|
|
299
|
+
credentialsPath?: string
|
|
300
|
+
/** openai: path to Codex auth.json */
|
|
301
|
+
codexAuthPath?: string
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type UsageBarConfig = {
|
|
305
|
+
showBars: boolean
|
|
306
|
+
showStatus: boolean
|
|
307
|
+
barWidth?: number
|
|
308
|
+
providers: Record<ProviderId, ProviderConfig>
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type Provider = {
|
|
312
|
+
id: ProviderId
|
|
313
|
+
/** short prefix shown when multiple providers are visible */
|
|
314
|
+
short: string
|
|
315
|
+
/** vendor status page JSON endpoint (Statuspage `status.json`); optional */
|
|
316
|
+
statusUrl?: string
|
|
317
|
+
fetchUsage(cfg: ProviderConfig): Promise<UsageWindow[] | null>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Helpers
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function expandTilde(p: string) {
|
|
325
|
+
return p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function fmtDuration(ms: number) {
|
|
329
|
+
const totalMinutes = Math.max(0, Math.ceil(ms / 60_000))
|
|
330
|
+
const days = Math.floor(totalMinutes / 1440)
|
|
331
|
+
const hours = Math.floor((totalMinutes % 1440) / 60)
|
|
332
|
+
const minutes = totalMinutes % 60
|
|
333
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
334
|
+
return `${hours}h ${minutes}m`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Decode a JWT's `exp` claim (unix seconds) without verifying. 0 on failure. */
|
|
338
|
+
function jwtExp(token: string): number {
|
|
339
|
+
try {
|
|
340
|
+
const payload = token.split('.')[1]
|
|
341
|
+
if (!payload) return 0
|
|
342
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
|
343
|
+
exp?: number
|
|
344
|
+
}
|
|
345
|
+
return typeof claims.exp === 'number' ? claims.exp : 0
|
|
346
|
+
} catch {
|
|
347
|
+
return 0
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown | null> {
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
354
|
+
if (!res.ok) return null
|
|
355
|
+
return await res.json()
|
|
356
|
+
} catch {
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Fetch a vendor's overall status from its public Statuspage JSON endpoint.
|
|
362
|
+
* Returns the `indicator` ("none" when healthy), or `null` when the fetch
|
|
363
|
+
* itself failed — so a network blip never clears a known incident. */
|
|
364
|
+
async function fetchStatus(url: string): Promise<StatusIndicator | null> {
|
|
365
|
+
const data = (await fetchJson(url, {})) as {
|
|
366
|
+
status?: { indicator?: string }
|
|
367
|
+
} | null
|
|
368
|
+
if (!data?.status) return null
|
|
369
|
+
const indicator = data.status.indicator
|
|
370
|
+
return indicator === 'minor' ||
|
|
371
|
+
indicator === 'major' ||
|
|
372
|
+
indicator === 'critical' ||
|
|
373
|
+
indicator === 'maintenance'
|
|
374
|
+
? indicator
|
|
375
|
+
: 'none'
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// opencode auth store (fallback credential source)
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
type OpencodeAuthEntry = {
|
|
383
|
+
type?: string
|
|
384
|
+
access?: string
|
|
385
|
+
expires?: number // epoch ms; 0 = no expiry
|
|
386
|
+
accountId?: string
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Set from `api.state.path.state` at startup; default matches opencode's
|
|
390
|
+
* XDG data dir. */
|
|
391
|
+
let opencodeAuthFile = join(homedir(), '.local', 'share', 'opencode', 'auth.json')
|
|
392
|
+
|
|
393
|
+
/** Read a provider's entry from opencode's own auth store
|
|
394
|
+
* (`opencode auth login`). Returns null when missing/unreadable. */
|
|
395
|
+
async function opencodeAuth(...ids: string[]): Promise<OpencodeAuthEntry | null> {
|
|
396
|
+
try {
|
|
397
|
+
const auth = JSON.parse(await readFile(opencodeAuthFile, 'utf8')) as Record<
|
|
398
|
+
string,
|
|
399
|
+
OpencodeAuthEntry
|
|
400
|
+
>
|
|
401
|
+
for (const id of ids) {
|
|
402
|
+
const entry = auth[id]
|
|
403
|
+
if (entry) return entry
|
|
404
|
+
}
|
|
405
|
+
return null
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Providers
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/** Claude Pro/Max — Anthropic's OAuth usage endpoint (same one Claude Code's
|
|
416
|
+
* `/usage` uses). Token from ~/.claude/.credentials.json, falling back to
|
|
417
|
+
* opencode's own auth store; sent only to api.anthropic.com. */
|
|
418
|
+
const anthropicProvider: Provider = {
|
|
419
|
+
id: 'anthropic',
|
|
420
|
+
short: 'cld',
|
|
421
|
+
statusUrl: 'https://status.anthropic.com/api/v2/status.json',
|
|
422
|
+
async fetchUsage(cfg) {
|
|
423
|
+
let token: string | undefined
|
|
424
|
+
try {
|
|
425
|
+
const path = expandTilde(
|
|
426
|
+
cfg.credentialsPath ?? join(homedir(), '.claude', '.credentials.json'),
|
|
427
|
+
)
|
|
428
|
+
const creds = JSON.parse(await readFile(path, 'utf8')) as {
|
|
429
|
+
claudeAiOauth?: { accessToken?: string; expiresAt?: number }
|
|
430
|
+
}
|
|
431
|
+
const oauth = creds.claudeAiOauth
|
|
432
|
+
if (oauth?.accessToken && !(oauth.expiresAt && Date.now() >= oauth.expiresAt))
|
|
433
|
+
token = oauth.accessToken
|
|
434
|
+
} catch {
|
|
435
|
+
// fall through to opencode auth
|
|
436
|
+
}
|
|
437
|
+
if (!token) {
|
|
438
|
+
const entry = await opencodeAuth('anthropic')
|
|
439
|
+
if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) token = entry.access
|
|
440
|
+
}
|
|
441
|
+
if (!token) return null
|
|
442
|
+
|
|
443
|
+
const data = (await fetchJson('https://api.anthropic.com/api/oauth/usage', {
|
|
444
|
+
authorization: `Bearer ${token}`,
|
|
445
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
446
|
+
})) as {
|
|
447
|
+
limits?: Array<{
|
|
448
|
+
kind?: string
|
|
449
|
+
percent?: number
|
|
450
|
+
resets_at?: string
|
|
451
|
+
scope?: { model?: { display_name?: string | null } | null } | null
|
|
452
|
+
}>
|
|
453
|
+
} | null
|
|
454
|
+
if (!data || !Array.isArray(data.limits)) return null
|
|
455
|
+
|
|
456
|
+
const windows: UsageWindow[] = []
|
|
457
|
+
for (const limit of data.limits) {
|
|
458
|
+
if (!limit || typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) continue
|
|
459
|
+
if (!limit.kind || !limit.resets_at) continue
|
|
460
|
+
const resetsAt = Date.parse(limit.resets_at)
|
|
461
|
+
if (Number.isNaN(resetsAt)) continue
|
|
462
|
+
const category: WindowCategory =
|
|
463
|
+
limit.kind === 'session' ? '5h' : limit.kind === 'weekly_all' ? '7d' : 'model'
|
|
464
|
+
const label = category === 'model' ? (limit.scope?.model?.display_name ?? 'model') : category
|
|
465
|
+
windows.push({ category, label, percent: limit.percent, resetsAt })
|
|
466
|
+
}
|
|
467
|
+
// Session window first, then the rest in API order.
|
|
468
|
+
windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
|
|
469
|
+
return windows.length > 0 ? windows : null
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** ChatGPT Plus/Pro (Codex) — reads the Codex CLI login and asks the wham
|
|
474
|
+
* usage endpoint. Read-only: never refreshes/rewrites auth.json; when the
|
|
475
|
+
* token is expired we simply hide (Codex CLI refreshes the file itself). */
|
|
476
|
+
const openaiProvider: Provider = {
|
|
477
|
+
id: 'openai',
|
|
478
|
+
short: 'oai',
|
|
479
|
+
statusUrl: 'https://status.openai.com/api/v2/status.json',
|
|
480
|
+
async fetchUsage(cfg) {
|
|
481
|
+
let accessToken: string | undefined
|
|
482
|
+
let accountId: string | undefined
|
|
483
|
+
try {
|
|
484
|
+
const path = expandTilde(cfg.codexAuthPath ?? join(homedir(), '.codex', 'auth.json'))
|
|
485
|
+
const auth = JSON.parse(await readFile(path, 'utf8')) as {
|
|
486
|
+
tokens?: { access_token?: string; id_token?: string; account_id?: string }
|
|
487
|
+
}
|
|
488
|
+
const tokens = auth.tokens
|
|
489
|
+
// Check the expiry of the token we actually send (the access token);
|
|
490
|
+
// the id_token expires much earlier and would cause false negatives.
|
|
491
|
+
const exp = tokens?.access_token ? jwtExp(tokens.access_token) : 0
|
|
492
|
+
if (tokens?.access_token && !(exp > 0 && exp * 1000 <= Date.now() + 60_000)) {
|
|
493
|
+
accessToken = tokens.access_token
|
|
494
|
+
accountId = tokens.account_id
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
// fall through to opencode auth
|
|
498
|
+
}
|
|
499
|
+
if (!accessToken) {
|
|
500
|
+
const entry = await opencodeAuth('openai')
|
|
501
|
+
if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) {
|
|
502
|
+
accessToken = entry.access
|
|
503
|
+
accountId = entry.accountId
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!accessToken) return null
|
|
151
507
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
508
|
+
const headers: Record<string, string> = {
|
|
509
|
+
authorization: `Bearer ${accessToken}`,
|
|
510
|
+
'user-agent': 'codex-cli',
|
|
511
|
+
}
|
|
512
|
+
if (accountId) headers['chatgpt-account-id'] = accountId
|
|
513
|
+
|
|
514
|
+
const data = (await fetchJson('https://chatgpt.com/backend-api/wham/usage', headers)) as {
|
|
515
|
+
rate_limit?: {
|
|
516
|
+
primary_window?: WhamWindow | null
|
|
517
|
+
secondary_window?: WhamWindow | null
|
|
518
|
+
}
|
|
519
|
+
} | null
|
|
520
|
+
if (!data?.rate_limit) return null
|
|
521
|
+
|
|
522
|
+
// Classify by window duration when present (some accounts return the
|
|
523
|
+
// weekly window as primary_window), falling back to position.
|
|
524
|
+
const windows: UsageWindow[] = []
|
|
525
|
+
const primary = parseWhamWindow(data.rate_limit.primary_window, '5h')
|
|
526
|
+
if (primary) windows.push(primary)
|
|
527
|
+
const secondary = parseWhamWindow(data.rate_limit.secondary_window, '7d')
|
|
528
|
+
if (secondary) windows.push(secondary)
|
|
529
|
+
windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
|
|
530
|
+
return windows.length > 0 ? windows : null
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
type WhamWindow = {
|
|
535
|
+
used_percent?: number
|
|
536
|
+
limit_window_seconds?: number
|
|
537
|
+
reset_at?: number
|
|
538
|
+
reset_after_seconds?: number
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function parseWhamWindow(
|
|
542
|
+
w: WhamWindow | null | undefined,
|
|
543
|
+
fallback: WindowCategory,
|
|
544
|
+
): UsageWindow | null {
|
|
545
|
+
if (!w || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return null
|
|
546
|
+
let resetsAt: number | undefined
|
|
547
|
+
if (typeof w.reset_at === 'number') resetsAt = w.reset_at * 1000
|
|
548
|
+
else if (typeof w.reset_after_seconds === 'number')
|
|
549
|
+
resetsAt = Date.now() + w.reset_after_seconds * 1000
|
|
550
|
+
if (!resetsAt || !Number.isFinite(resetsAt)) return null
|
|
551
|
+
const category: WindowCategory =
|
|
552
|
+
typeof w.limit_window_seconds === 'number' && w.limit_window_seconds > 0
|
|
553
|
+
? w.limit_window_seconds <= 21_600 // ≤ 6h → session window
|
|
554
|
+
? '5h'
|
|
555
|
+
: '7d'
|
|
556
|
+
: fallback
|
|
557
|
+
return { category, label: category, percent: w.used_percent, resetsAt }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** OpenCode Go/Zen — dollar-denominated rolling usage limits ($12 / 5h,
|
|
561
|
+
* $30 / 7d, $60 / subscription month). Token is the same key used for
|
|
562
|
+
* inference: PANTHEON_OPENCODE_API_KEY, falling back to OPENCODE_API_KEY.
|
|
563
|
+
* It is only ever sent in the Authorization header to opencode.ai — never
|
|
564
|
+
* logged or written to disk. The usage endpoint (opencode.ai/zen/go/v1/
|
|
565
|
+
* usage) is not yet part of the public docs, so any non-ok response
|
|
566
|
+
* (404/401/403/500) hides the provider silently instead of erroring out. */
|
|
567
|
+
const opencodeGoProvider: Provider = {
|
|
568
|
+
id: 'opencodego',
|
|
569
|
+
short: 'go',
|
|
570
|
+
// No statusUrl — opencode.ai has no verified public status page to poll.
|
|
571
|
+
async fetchUsage(_cfg) {
|
|
572
|
+
const token = process.env.PANTHEON_OPENCODE_API_KEY ?? process.env.OPENCODE_API_KEY
|
|
573
|
+
if (!token) return null
|
|
574
|
+
|
|
575
|
+
const data = (await fetchJson('https://opencode.ai/zen/go/v1/usage', {
|
|
576
|
+
authorization: `Bearer ${token}`,
|
|
577
|
+
})) as {
|
|
578
|
+
rolling5h?: GoUsageWindow
|
|
579
|
+
weekly?: GoUsageWindow
|
|
580
|
+
monthly?: GoUsageWindow
|
|
581
|
+
} | null
|
|
582
|
+
if (!data) return null
|
|
583
|
+
|
|
584
|
+
const windows: UsageWindow[] = []
|
|
585
|
+
// Mirror the defensive parsing used by the other providers: a window
|
|
586
|
+
// missing its percent or reset time is skipped rather than rendered.
|
|
587
|
+
const push = (w: GoUsageWindow | undefined, category: '5h' | '7d' | '1m') => {
|
|
588
|
+
if (!w) return
|
|
589
|
+
if (typeof w.usagePercent !== 'number' || !Number.isFinite(w.usagePercent)) return
|
|
590
|
+
if (typeof w.resetInSec !== 'number' || !Number.isFinite(w.resetInSec)) return
|
|
591
|
+
windows.push({
|
|
592
|
+
category,
|
|
593
|
+
label: category,
|
|
594
|
+
percent: Math.round(w.usagePercent),
|
|
595
|
+
resetsAt: Date.now() + w.resetInSec * 1000,
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
push(data.rolling5h, '5h')
|
|
599
|
+
push(data.weekly, '7d')
|
|
600
|
+
push(data.monthly, '1m')
|
|
601
|
+
return windows.length > 0 ? windows : null
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
type GoUsageWindow = {
|
|
606
|
+
usageDollars?: number
|
|
607
|
+
limitDollars?: number
|
|
608
|
+
usagePercent?: number
|
|
609
|
+
resetInSec?: number
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const providers: Provider[] = [anthropicProvider, openaiProvider, opencodeGoProvider]
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Config
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
const DEFAULT_TOML = `# opencode-usage-bar configuration
|
|
619
|
+
# Read once at startup — restart opencode after editing.
|
|
620
|
+
|
|
621
|
+
[ui]
|
|
622
|
+
show_bars = true # render ▓▓░░ mini-bars (false = text only)
|
|
623
|
+
show_status = true # show a ! marker next to a provider during incidents
|
|
624
|
+
# bar_width = 6 # override bar width (default: 6 for a single window, 5 otherwise)
|
|
625
|
+
|
|
626
|
+
[anthropic]
|
|
627
|
+
enabled = true # Claude Pro/Max via ~/.claude/.credentials.json
|
|
628
|
+
show_5h = true # rolling 5-hour session window
|
|
629
|
+
show_7d = false # weekly cap across all models
|
|
630
|
+
show_model = false # per-model weekly windows (e.g. Fable)
|
|
631
|
+
# credentials_path = "~/.claude/.credentials.json"
|
|
632
|
+
|
|
633
|
+
[openai]
|
|
634
|
+
enabled = false # ChatGPT Plus/Pro via the Codex CLI login
|
|
635
|
+
show_5h = true
|
|
636
|
+
show_7d = false
|
|
637
|
+
# codex_auth_path = "~/.codex/auth.json"
|
|
638
|
+
|
|
639
|
+
[opencodego]
|
|
640
|
+
enabled = true # OpenCode Go/Zen dollar usage via PANTHEON_OPENCODE_API_KEY
|
|
641
|
+
show_5h = true # rolling 5-hour window ($12)
|
|
642
|
+
show_7d = true # rolling 7-day window ($30)
|
|
643
|
+
show_1m = false # subscription-month window ($60) — opt-in
|
|
644
|
+
# Falls back to OPENCODE_API_KEY when PANTHEON_OPENCODE_API_KEY is unset.
|
|
645
|
+
`
|
|
646
|
+
|
|
647
|
+
function defaultConfig(): UsageBarConfig {
|
|
648
|
+
const show = (over: Partial<Record<WindowCategory, boolean>> = {}) => ({
|
|
649
|
+
'5h': true,
|
|
650
|
+
'7d': false,
|
|
651
|
+
'1m': false,
|
|
652
|
+
model: false,
|
|
653
|
+
...over,
|
|
157
654
|
})
|
|
655
|
+
return {
|
|
656
|
+
showBars: true,
|
|
657
|
+
showStatus: true,
|
|
658
|
+
providers: {
|
|
659
|
+
anthropic: { enabled: true, show: show() },
|
|
660
|
+
openai: { enabled: false, show: show() },
|
|
661
|
+
// 5h+7d on by default; monthly window opt-in; model never for Go.
|
|
662
|
+
opencodego: { enabled: true, show: show({ '7d': true }) },
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
}
|
|
158
666
|
|
|
159
|
-
|
|
667
|
+
type TomlTable = Record<string, unknown>
|
|
668
|
+
|
|
669
|
+
function asTable(v: unknown): TomlTable {
|
|
670
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as TomlTable) : {}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function bool(v: unknown, fallback: boolean) {
|
|
674
|
+
return typeof v === 'boolean' ? v : fallback
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function str(v: unknown): string | undefined {
|
|
678
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function parseConfig(raw: string): UsageBarConfig {
|
|
682
|
+
const toml = (globalThis as Record<string, any>).Bun?.TOML
|
|
683
|
+
if (!toml?.parse) return defaultConfig()
|
|
684
|
+
const root = asTable(toml.parse(raw))
|
|
685
|
+
const cfg = defaultConfig()
|
|
686
|
+
|
|
687
|
+
const ui = asTable(root['ui'])
|
|
688
|
+
cfg.showBars = bool(ui['show_bars'], cfg.showBars)
|
|
689
|
+
cfg.showStatus = bool(ui['show_status'], cfg.showStatus)
|
|
690
|
+
const rawWidth = ui['bar_width']
|
|
691
|
+
if (typeof rawWidth === 'number' && Number.isFinite(rawWidth) && rawWidth >= 1)
|
|
692
|
+
cfg.barWidth = Math.min(40, Math.floor(rawWidth))
|
|
693
|
+
|
|
694
|
+
for (const id of ['anthropic', 'openai', 'opencodego'] as ProviderId[]) {
|
|
695
|
+
const t = asTable(root[id])
|
|
696
|
+
const p = cfg.providers[id]
|
|
697
|
+
p.enabled = bool(t['enabled'], p.enabled)
|
|
698
|
+
p.show['5h'] = bool(t['show_5h'], p.show['5h'])
|
|
699
|
+
p.show['7d'] = bool(t['show_7d'], p.show['7d'])
|
|
700
|
+
p.show['1m'] = bool(t['show_1m'], p.show['1m'])
|
|
701
|
+
p.show.model = bool(t['show_model'], p.show.model)
|
|
702
|
+
// Assign only when present — keeps `exactOptionalPropertyTypes` happy.
|
|
703
|
+
const credentialsPath = str(t['credentials_path'])
|
|
704
|
+
if (credentialsPath !== undefined) p.credentialsPath = credentialsPath
|
|
705
|
+
const codexAuthPath = str(t['codex_auth_path'])
|
|
706
|
+
if (codexAuthPath !== undefined) p.codexAuthPath = codexAuthPath
|
|
707
|
+
}
|
|
708
|
+
return cfg
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Load `<config dir>/usage-bar.toml`, creating it with commented defaults on
|
|
712
|
+
* first run. Any failure falls back to defaults without touching an existing
|
|
713
|
+
* file. */
|
|
714
|
+
async function loadConfig(configPath: string | undefined): Promise<UsageBarConfig> {
|
|
715
|
+
const dir =
|
|
716
|
+
configPath && !configPath.endsWith('.json')
|
|
717
|
+
? configPath
|
|
718
|
+
: configPath
|
|
719
|
+
? dirname(configPath)
|
|
720
|
+
: join(homedir(), '.config', 'opencode')
|
|
721
|
+
const file = join(dir, CONFIG_FILE)
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const raw = await readFile(file, 'utf8')
|
|
725
|
+
try {
|
|
726
|
+
return parseConfig(raw)
|
|
727
|
+
} catch {
|
|
728
|
+
return defaultConfig() // malformed TOML — keep the file, use defaults
|
|
729
|
+
}
|
|
730
|
+
} catch (err) {
|
|
731
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
732
|
+
try {
|
|
733
|
+
await mkdir(dir, { recursive: true })
|
|
734
|
+
await writeFile(file, DEFAULT_TOML, { flag: 'wx' })
|
|
735
|
+
} catch {
|
|
736
|
+
// ignore — config stays default in memory
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return defaultConfig()
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Vendored usage-bar plugin init: reads config, seeds the kv cache, starts
|
|
744
|
+
* the poll loop and registers the `app_bottom` slot. Skips everything (and
|
|
745
|
+
* registers nothing) when no provider is enabled — mirrors the standalone
|
|
746
|
+
* plugin's early return. */
|
|
747
|
+
async function setupUsageBar(api: TuiPluginApi) {
|
|
748
|
+
const config = await loadConfig(api.state.path?.config)
|
|
749
|
+
if (api.state.path?.state) opencodeAuthFile = join(api.state.path.state, 'auth.json')
|
|
750
|
+
|
|
751
|
+
const enabled = providers.filter((p) => config.providers[p.id].enabled)
|
|
752
|
+
if (enabled.length === 0) return // nothing to poll or render
|
|
753
|
+
|
|
754
|
+
const seed: Record<string, UsageWindow[]> = {}
|
|
755
|
+
for (const p of enabled) {
|
|
756
|
+
const cached = api.kv.get<UsageWindow[] | undefined>(`usage-bar.${p.id}.windows`, undefined)
|
|
757
|
+
if (cached) seed[p.id] = cached
|
|
758
|
+
}
|
|
759
|
+
const [byProvider, setByProvider] = createSignal<Record<string, UsageWindow[]>>(seed)
|
|
760
|
+
// Status is deliberately not cached across restarts: incidents are
|
|
761
|
+
// short-lived, and the first poll lands seconds after startup anyway.
|
|
762
|
+
const [byStatus, setByStatus] = createSignal<Record<string, StatusIndicator>>({})
|
|
763
|
+
const [now, setNow] = createSignal(Date.now())
|
|
764
|
+
|
|
765
|
+
setInterval(() => setNow(Date.now()), 1_000)
|
|
766
|
+
|
|
767
|
+
for (const p of enabled) {
|
|
768
|
+
const cfg = config.providers[p.id]
|
|
769
|
+
const poll = async () => {
|
|
770
|
+
// Fetch usage and status concurrently; status pages are CDN-backed and
|
|
771
|
+
// never throttle, so we poll them on the same cadence as usage.
|
|
772
|
+
const statusP = config.showStatus && p.statusUrl ? fetchStatus(p.statusUrl) : null
|
|
773
|
+
const [all, status] = await Promise.all([p.fetchUsage(cfg), statusP])
|
|
774
|
+
if (all) {
|
|
775
|
+
const windows = all.filter((w) => cfg.show[w.category] && w.resetsAt > Date.now())
|
|
776
|
+
setByProvider((prev) => ({ ...prev, [p.id]: windows }))
|
|
777
|
+
api.kv.set(`usage-bar.${p.id}.windows`, windows)
|
|
778
|
+
}
|
|
779
|
+
// `null` means the status fetch failed — keep the last known indicator.
|
|
780
|
+
if (status !== null) setByStatus((prev) => ({ ...prev, [p.id]: status }))
|
|
781
|
+
// Back off when the usage fetch failed (e.g. 429 — these endpoints throttle).
|
|
782
|
+
setTimeout(poll, all ? POLL_MS : POLL_MS * 3)
|
|
783
|
+
}
|
|
784
|
+
void poll()
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
api.slots.register({
|
|
788
|
+
order: 60,
|
|
789
|
+
slots: {
|
|
790
|
+
// `app_bottom` renders as a full-width row below the session footer.
|
|
791
|
+
app_bottom() {
|
|
792
|
+
const theme = () => api.theme.current
|
|
793
|
+
|
|
794
|
+
type Group = { short: string; status: StatusIndicator; windows: UsageWindow[] }
|
|
795
|
+
const groups = createMemo<Group[]>(() => {
|
|
796
|
+
const map = byProvider()
|
|
797
|
+
const statusMap = byStatus()
|
|
798
|
+
const out: Group[] = []
|
|
799
|
+
for (const p of enabled) {
|
|
800
|
+
// Drop windows that have reset since the last poll.
|
|
801
|
+
const windows = (map[p.id] ?? []).filter((w) => w.resetsAt > now())
|
|
802
|
+
if (windows.length > 0)
|
|
803
|
+
out.push({ short: p.short, status: statusMap[p.id] ?? 'none', windows })
|
|
804
|
+
}
|
|
805
|
+
return out
|
|
806
|
+
})
|
|
807
|
+
const totalWindows = createMemo(() =>
|
|
808
|
+
groups().reduce((sum, g) => sum + g.windows.length, 0),
|
|
809
|
+
)
|
|
810
|
+
const barWidth = createMemo(() => config.barWidth ?? (totalWindows() === 1 ? 6 : 5))
|
|
811
|
+
const multiProvider = createMemo(() => groups().length >= 2)
|
|
812
|
+
|
|
813
|
+
const pctOf = (w: UsageWindow) => Math.min(100, Math.max(0, Math.round(w.percent)))
|
|
814
|
+
const filledOf = (w: UsageWindow) =>
|
|
815
|
+
Math.min(barWidth(), Math.max(0, Math.round((pctOf(w) / 100) * barWidth())))
|
|
816
|
+
const colorOf = (w: UsageWindow) => {
|
|
817
|
+
const t = theme()
|
|
818
|
+
const pct = pctOf(w)
|
|
819
|
+
if (pct > 85) return t.error
|
|
820
|
+
if (pct >= 50) return t.warning
|
|
821
|
+
return t.success
|
|
822
|
+
}
|
|
823
|
+
// Status marker color: red for severe incidents, amber for minor,
|
|
824
|
+
// cyan for scheduled maintenance.
|
|
825
|
+
const statusColor = (s: StatusIndicator) => {
|
|
826
|
+
const t = theme()
|
|
827
|
+
if (s === 'critical' || s === 'major') return t.error
|
|
828
|
+
if (s === 'minor') return t.warning
|
|
829
|
+
return t.info
|
|
830
|
+
}
|
|
160
831
|
|
|
161
|
-
return (
|
|
162
|
-
<Show when={usage()}>
|
|
163
|
-
{(u) => {
|
|
164
|
-
const pct = u().percent
|
|
165
|
-
const c = theme()
|
|
166
|
-
const barColor = pct >= 90 ? c.error : pct >= 70 ? c.warning : c.accent
|
|
167
|
-
const costStr = fmtCost(totalCost())
|
|
168
832
|
return (
|
|
169
|
-
<
|
|
170
|
-
<box flexDirection="row" gap={1}>
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
833
|
+
<Show when={groups().length > 0}>
|
|
834
|
+
<box flexDirection="row" gap={3} alignItems="center" width="100%" paddingLeft={1}>
|
|
835
|
+
<For each={groups()}>
|
|
836
|
+
{(g) => (
|
|
837
|
+
<box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
|
|
838
|
+
<Show when={config.showStatus && g.status !== 'none'}>
|
|
839
|
+
<text fg={statusColor(g.status)}>!</text>
|
|
840
|
+
</Show>
|
|
841
|
+
<Show when={multiProvider()}>
|
|
842
|
+
<text fg={theme().textMuted}>{g.short}</text>
|
|
843
|
+
</Show>
|
|
844
|
+
<For each={g.windows}>
|
|
845
|
+
{(w) => (
|
|
846
|
+
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
|
847
|
+
<Show when={g.windows.length >= 2}>
|
|
848
|
+
<text fg={theme().textMuted}>{w.label}</text>
|
|
849
|
+
</Show>
|
|
850
|
+
<Show when={config.showBars}>
|
|
851
|
+
<box flexDirection="row">
|
|
852
|
+
<text fg={colorOf(w)}>{'▓'.repeat(filledOf(w))}</text>
|
|
853
|
+
<text fg={theme().textMuted}>
|
|
854
|
+
{'░'.repeat(barWidth() - filledOf(w))}
|
|
855
|
+
</text>
|
|
856
|
+
</box>
|
|
857
|
+
</Show>
|
|
858
|
+
<text fg={theme().text}>{pctOf(w)}%</text>
|
|
859
|
+
<text
|
|
860
|
+
fg={theme().textMuted}
|
|
861
|
+
>{`· ${fmtDuration(w.resetsAt - now())}`}</text>
|
|
862
|
+
</box>
|
|
863
|
+
)}
|
|
864
|
+
</For>
|
|
865
|
+
</box>
|
|
866
|
+
)}
|
|
867
|
+
</For>
|
|
174
868
|
</box>
|
|
175
|
-
|
|
176
|
-
{`${fmtInt(u().tokens)} / ${fmtInt(u().limit)} tok${costStr ? ` ${costStr}` : ''}`}
|
|
177
|
-
</text>
|
|
178
|
-
</box>
|
|
869
|
+
</Show>
|
|
179
870
|
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
)
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
})
|
|
183
874
|
}
|
|
184
875
|
|
|
185
876
|
/* ─── Session Row (single session with live status indicator) ─ */
|
|
@@ -191,15 +882,20 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
|
|
|
191
882
|
try {
|
|
192
883
|
const s = (props.api.state as any).session?.status?.(props.session.id)
|
|
193
884
|
if (s?.type) return s.type as string
|
|
194
|
-
} catch {
|
|
885
|
+
} catch {
|
|
886
|
+
/* ignore */
|
|
887
|
+
}
|
|
195
888
|
return null
|
|
196
889
|
})
|
|
197
890
|
|
|
198
891
|
const statusIcon = createMemo(() => {
|
|
199
892
|
switch (status()) {
|
|
200
|
-
case 'busy':
|
|
201
|
-
|
|
202
|
-
|
|
893
|
+
case 'busy':
|
|
894
|
+
return '\u25cf ' // ● — running
|
|
895
|
+
case 'retry':
|
|
896
|
+
return '\u26a0 ' // ⚠ — needs attention
|
|
897
|
+
default:
|
|
898
|
+
return '' // idle/unknown — no icon
|
|
203
899
|
}
|
|
204
900
|
})
|
|
205
901
|
|
|
@@ -224,6 +920,31 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
224
920
|
props.api.state.vcs?.branch ? `\u2387 ${props.api.state.vcs.branch}` : null,
|
|
225
921
|
)
|
|
226
922
|
|
|
923
|
+
// ── Active preset indicator ──
|
|
924
|
+
// Seeded synchronously from env (never blocks first paint), then re-checked
|
|
925
|
+
// against .pantheon/active-preset.json on mount and every 30s so `set-tier`
|
|
926
|
+
// changes made while opencode is open become visible.
|
|
927
|
+
const [preset, setPreset] = createSignal<PresetInfo>(presetFromEnv(process.env))
|
|
928
|
+
|
|
929
|
+
onMount(() => {
|
|
930
|
+
const cwd = ((props.api.state as any).path?.worktree ?? '') || process.cwd()
|
|
931
|
+
let cancelled = false
|
|
932
|
+
const refresh = async () => {
|
|
933
|
+
try {
|
|
934
|
+
const info = await resolvePresetForTui(process.env, cwd)
|
|
935
|
+
if (!cancelled) setPreset(info)
|
|
936
|
+
} catch {
|
|
937
|
+
// transient read error — keep the last known value
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
void refresh()
|
|
941
|
+
const timer = setInterval(() => void refresh(), PRESET_REFRESH_MS)
|
|
942
|
+
onCleanup(() => {
|
|
943
|
+
cancelled = true
|
|
944
|
+
clearInterval(timer)
|
|
945
|
+
})
|
|
946
|
+
})
|
|
947
|
+
|
|
227
948
|
// ── Sessions: fetch via api.client.session.list (not proc.exec) ──
|
|
228
949
|
const [sessionList, { refetch: refetchSessions }] = createResource(async () => {
|
|
229
950
|
try {
|
|
@@ -272,22 +993,30 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
272
993
|
cleanup.push(props.api.event.on('session.created', refetchSessions))
|
|
273
994
|
cleanup.push(props.api.event.on('session.updated', refetchSessions))
|
|
274
995
|
cleanup.push(props.api.event.on('session.deleted', refetchSessions))
|
|
275
|
-
} catch {
|
|
996
|
+
} catch {
|
|
997
|
+
/* events API not available in this runtime */
|
|
998
|
+
}
|
|
276
999
|
|
|
277
1000
|
// Memory events — tracks running counter (no api.state.memory)
|
|
278
1001
|
try {
|
|
279
|
-
const ev =
|
|
1002
|
+
const ev = props.api.event as any
|
|
280
1003
|
if (typeof ev?.on === 'function') {
|
|
1004
|
+
cleanup.push(ev.on('memory.created', () => setMemoryCount((c) => (c !== null ? c + 1 : 1))))
|
|
281
1005
|
cleanup.push(
|
|
282
|
-
ev.on('memory.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
ev.on('memory.deleted', () => setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0))),
|
|
1006
|
+
ev.on('memory.deleted', () =>
|
|
1007
|
+
setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0)),
|
|
1008
|
+
),
|
|
286
1009
|
)
|
|
287
1010
|
}
|
|
288
|
-
} catch {
|
|
1011
|
+
} catch {
|
|
1012
|
+
/* memory events not available */
|
|
1013
|
+
}
|
|
289
1014
|
|
|
290
|
-
onCleanup(() =>
|
|
1015
|
+
onCleanup(() =>
|
|
1016
|
+
cleanup.forEach((fn) => {
|
|
1017
|
+
fn()
|
|
1018
|
+
}),
|
|
1019
|
+
)
|
|
291
1020
|
})
|
|
292
1021
|
|
|
293
1022
|
// ── Config summary ──
|
|
@@ -306,23 +1035,34 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
306
1035
|
return (
|
|
307
1036
|
<box flexDirection="column" width="100%">
|
|
308
1037
|
{/* ── Header ── */}
|
|
309
|
-
<text fg={theme().accent} attributes={
|
|
1038
|
+
<text fg={theme().accent} attributes={1}>
|
|
310
1039
|
{`Pantheon${props.version ? ` v${props.version}` : ''}`}
|
|
311
1040
|
</text>
|
|
312
|
-
<Show when={branch()}>
|
|
313
|
-
{(b) => <text fg={theme().textMuted}>{b()}</text>}
|
|
314
|
-
</Show>
|
|
315
|
-
|
|
316
|
-
<HR />
|
|
1041
|
+
<Show when={branch()}>{(b) => <text fg={theme().textMuted}>{b()}</text>}</Show>
|
|
317
1042
|
|
|
318
|
-
{/* ──
|
|
319
|
-
<
|
|
1043
|
+
{/* ── Active preset indicator ── */}
|
|
1044
|
+
<Show
|
|
1045
|
+
when={preset().name}
|
|
1046
|
+
fallback={
|
|
1047
|
+
<box flexDirection="row" gap={1}>
|
|
1048
|
+
<text fg={theme().textMuted}>Preset: default</text>
|
|
1049
|
+
</box>
|
|
1050
|
+
}
|
|
1051
|
+
>
|
|
1052
|
+
{(name) => (
|
|
1053
|
+
<box flexDirection="row" gap={1}>
|
|
1054
|
+
<text fg={theme().textMuted}>{'\u26a1 Preset:'}</text>
|
|
1055
|
+
<text fg={theme().accent}>{name()}</text>
|
|
1056
|
+
<text fg={theme().textMuted}>{`(${preset().source ?? ''})`}</text>
|
|
1057
|
+
</box>
|
|
1058
|
+
)}
|
|
1059
|
+
</Show>
|
|
320
1060
|
|
|
321
1061
|
<HR />
|
|
322
1062
|
|
|
323
1063
|
{/* ── Sessions ── */}
|
|
324
1064
|
<box onMouseDown={() => setShowSessions((x) => !x)}>
|
|
325
|
-
<text fg={theme().text} attributes={
|
|
1065
|
+
<text fg={theme().text} attributes={1}>
|
|
326
1066
|
{`${showSessions() ? '\u25bc' : '\u25b6'} Sessions`}
|
|
327
1067
|
</text>
|
|
328
1068
|
<text fg={theme().textMuted}>{` (${String(totalSessions())})`}</text>
|
|
@@ -330,7 +1070,11 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
330
1070
|
<Show when={showSessions()}>
|
|
331
1071
|
<Show
|
|
332
1072
|
when={recentSessions().length > 0}
|
|
333
|
-
fallback={
|
|
1073
|
+
fallback={
|
|
1074
|
+
<box marginLeft={1}>
|
|
1075
|
+
<text fg={theme().textMuted}>No recent sessions</text>
|
|
1076
|
+
</box>
|
|
1077
|
+
}
|
|
334
1078
|
>
|
|
335
1079
|
<box marginLeft={1} flexDirection="column">
|
|
336
1080
|
<For each={recentSessions()}>
|
|
@@ -342,7 +1086,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
342
1086
|
|
|
343
1087
|
{/* ── Commands ── */}
|
|
344
1088
|
<box onMouseDown={() => setShowCommands((x) => !x)}>
|
|
345
|
-
<text fg={theme().text} attributes={
|
|
1089
|
+
<text fg={theme().text} attributes={1}>
|
|
346
1090
|
{`${showCommands() ? '\u25bc' : '\u25b6'} Commands`}
|
|
347
1091
|
</text>
|
|
348
1092
|
<text fg={theme().textMuted}>{` (${String(COMMANDS.length)})`}</text>
|
|
@@ -351,15 +1095,19 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
351
1095
|
<box marginLeft={1} flexDirection="column">
|
|
352
1096
|
<For each={COMMANDS}>
|
|
353
1097
|
{(cmd) => (
|
|
354
|
-
<box
|
|
355
|
-
e
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
1098
|
+
<box
|
|
1099
|
+
onMouseDown={(e) => {
|
|
1100
|
+
e.stopPropagation()
|
|
1101
|
+
try {
|
|
1102
|
+
const cmdApi = (props.api as any).command
|
|
1103
|
+
const cmdName = cmd.name.replace('/', '')
|
|
1104
|
+
if (cmdApi?.trigger?.(cmdName)) return
|
|
1105
|
+
} catch {
|
|
1106
|
+
/* fall through to toast */
|
|
1107
|
+
}
|
|
1108
|
+
props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
|
|
1109
|
+
}}
|
|
1110
|
+
>
|
|
363
1111
|
<text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>
|
|
364
1112
|
{cmd.name}
|
|
365
1113
|
</text>
|
|
@@ -372,7 +1120,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
372
1120
|
|
|
373
1121
|
{/* ── Agents ── */}
|
|
374
1122
|
<box onMouseDown={() => setShowAgents((x) => !x)}>
|
|
375
|
-
<text fg={theme().text} attributes={
|
|
1123
|
+
<text fg={theme().text} attributes={1}>
|
|
376
1124
|
{`${showAgents() ? '\u25bc' : '\u25b6'} Agents`}
|
|
377
1125
|
</text>
|
|
378
1126
|
<text fg={theme().textMuted}>{` (${String(AGENTS.length)})`}</text>
|
|
@@ -394,16 +1142,27 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
394
1142
|
|
|
395
1143
|
{/* ── Config ── */}
|
|
396
1144
|
<box onMouseDown={() => setShowConfig((x) => !x)}>
|
|
397
|
-
<text fg={theme().text} attributes={
|
|
1145
|
+
<text fg={theme().text} attributes={1}>
|
|
398
1146
|
{`${showConfig() ? '\u25bc' : '\u25b6'} Config`}
|
|
399
1147
|
</text>
|
|
400
1148
|
</box>
|
|
401
1149
|
<Show when={showConfig()}>
|
|
402
|
-
<Show
|
|
1150
|
+
<Show
|
|
1151
|
+
when={configSummary()}
|
|
1152
|
+
fallback={
|
|
1153
|
+
<box marginLeft={1}>
|
|
1154
|
+
<text fg={theme().textMuted}>No config data</text>
|
|
1155
|
+
</box>
|
|
1156
|
+
}
|
|
1157
|
+
>
|
|
403
1158
|
{(cfg) => (
|
|
404
1159
|
<box marginLeft={1} flexDirection="column">
|
|
405
|
-
<text
|
|
406
|
-
|
|
1160
|
+
<text
|
|
1161
|
+
fg={theme().textMuted}
|
|
1162
|
+
>{`MCP: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
|
|
1163
|
+
<text
|
|
1164
|
+
fg={theme().textMuted}
|
|
1165
|
+
>{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
|
|
407
1166
|
</box>
|
|
408
1167
|
)}
|
|
409
1168
|
</Show>
|
|
@@ -412,9 +1171,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
412
1171
|
{/* ── Memory (always visible summary) ── */}
|
|
413
1172
|
<box marginTop={1}>
|
|
414
1173
|
<text fg={theme().textMuted}>
|
|
415
|
-
{memoryCount() !== null
|
|
416
|
-
? `Memory: ${fmtInt(memoryCount()!)} entries`
|
|
417
|
-
: 'Memory: N/A'}
|
|
1174
|
+
{memoryCount() !== null ? `Memory: ${fmtInt(memoryCount()!)} entries` : 'Memory: N/A'}
|
|
418
1175
|
</text>
|
|
419
1176
|
</box>
|
|
420
1177
|
</box>
|
|
@@ -426,6 +1183,9 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
426
1183
|
const tui: TuiPlugin = async (api, _options, _meta) => {
|
|
427
1184
|
const version = await detectVersion(api)
|
|
428
1185
|
|
|
1186
|
+
// Vendored feature (MIT): AI subscription usage gauges.
|
|
1187
|
+
void setupUsageBar(api) // async init — never blocks sidebar registration
|
|
1188
|
+
|
|
429
1189
|
api.slots.register({
|
|
430
1190
|
order: 900,
|
|
431
1191
|
slots: {
|