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.
Files changed (63) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +112 -15
  3. package/bin/pantheon-init.mjs +93 -0
  4. package/commands/pantheon-vision.md +67 -0
  5. package/opencode.json +14 -10
  6. package/package.json +20 -4
  7. package/scripts/_pantheon_paths.py +0 -2
  8. package/scripts/changelog-extract.mjs +112 -0
  9. package/scripts/doctor.mjs +307 -15
  10. package/scripts/hooks/audit-imports.sh +43 -0
  11. package/scripts/hooks/format-multi-language.sh +96 -0
  12. package/scripts/hooks/log-session-start.sh +24 -0
  13. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  14. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  15. package/scripts/hooks/run-type-check.sh +21 -0
  16. package/scripts/hooks/scan-secrets.sh +55 -0
  17. package/scripts/hooks/validate-post-conditions.sh +21 -0
  18. package/scripts/hooks/validate-talos-scope.sh +43 -0
  19. package/scripts/hooks/validate-tool-safety.sh +46 -0
  20. package/scripts/install/health-check.mjs +101 -1
  21. package/scripts/install/model-picker.mjs +118 -0
  22. package/scripts/install/opencode.mjs +155 -43
  23. package/scripts/install/plugin.mjs +4 -2
  24. package/scripts/install/venv.mjs +25 -2
  25. package/scripts/install-mcp.mjs +170 -37
  26. package/scripts/mcp_persistence_server.py +2 -1
  27. package/scripts/prune.mjs +359 -0
  28. package/scripts/secret-scan.mjs +88 -0
  29. package/scripts/uninstall.mjs +3 -3
  30. package/scripts/validate-routing.mjs +15 -0
  31. package/scripts/version-check.mjs +146 -0
  32. package/scripts/versioning.mjs +13 -14
  33. package/src/agents/hermes.md +2 -2
  34. package/src/agents/mnemosyne.md +1 -1
  35. package/src/agents/zeus.md +1 -1
  36. package/src/mcp/_pantheon_paths.py +0 -2
  37. package/src/mcp/install-mcp.mjs +6 -913
  38. package/src/mcp/mcp_persistence_server.py +2 -1
  39. package/src/mcp/pantheon_vision_server.py +495 -0
  40. package/src/mcp/requirements-mcp.txt +8 -5
  41. package/src/mcp/requirements-vision.txt +3 -0
  42. package/src/pantheon/auto-wake.ts +91 -0
  43. package/src/pantheon/background-job-board.ts +568 -0
  44. package/src/pantheon/file-persistence.ts +95 -0
  45. package/src/pantheon/presets.d.mts +108 -0
  46. package/src/pantheon/presets.mjs +554 -0
  47. package/src/pantheon/vision.ts +1437 -0
  48. package/src/plugin.ts +69 -25
  49. package/src/plugins/hook-runner.ts +186 -0
  50. package/src/plugins/pantheon-hooks.ts +153 -0
  51. package/src/plugins/tui/dist/tui.d.ts +1 -1
  52. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  53. package/src/plugins/tui/dist/tui.js +812 -223
  54. package/src/plugins/tui/dist/tui.js.map +1 -1
  55. package/src/plugins/tui/dist/tui.tsx +881 -121
  56. package/src/plugins/tui/package.json +3 -3
  57. package/src/plugins/tui/src/index.tsx +881 -121
  58. package/src/routing.yml +148 -0
  59. package/src/skills/context-compression/SKILL.md +1 -1
  60. package/src/skills/loop-engineering/SKILL.md +1 -1
  61. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  62. package/scripts/scrub_secrets.py +0 -281
  63. 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
- function fmtCost(v: number): string {
54
- if (v <= 0) return ''
55
- if (v < 0.01) return '<$0.01'
56
- return `$${v.toFixed(2)}`
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
- function msgTokens(msg: any): number {
60
- const t = msg?.tokens ?? msg?.info?.tokens ?? {}
61
- return safeNum(t.input) + safeNum(t.output) + safeNum(t.reasoning)
62
- + safeNum(t?.cache?.read) + safeNum(t?.cache?.write)
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
- function msgCost(src: any): number {
66
- for (const c of [src?.cost, src?.info?.cost, src?.usage?.cost]) {
67
- if (typeof c === 'number' && c > 0) return c
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 0
146
+ return { name: null, source: null }
70
147
  }
71
148
 
72
- function buildBar(pct: number): string {
73
- const clamped = Math.max(0, Math.min(100, pct))
74
- const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((clamped / 100) * BAR_WIDTH)))
75
- return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
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({ query: { path: fp } })
94
- const match = String(result?.content ?? '').match(/"version":\s*"([^"]+)"/)
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 { /* fall through */ }
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({ command: 'git', args: ['describe', '--tags', '--always'], timeoutMs: 3000 })
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.trim().replace(/^v/, '').replace(/-\d+-g[0-9a-f]+$/, '')
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 { /* fall through */ }
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 { /* fall through */ }
225
+ } catch {
226
+ /* fall through */
227
+ }
119
228
 
120
229
  return null // sem fallback — versão não aparece
121
230
  }
122
231
 
123
- /* ─── Context Bar ──────────────────────────────────────────
124
- * Inspired by streetturtle/opencode-better-sidebar (context-progress)
125
- * Shows: progress bar, token usage / limit, session cost */
232
+ /* ──────────────────────────────────────────────────────────
233
+ * VENDORED: opencode-usage-bar (MIT) — satas20
234
+ * https://github.com/satas20/opencode-usage-bar
235
+ * ────────────────────────────────────────────────────────── */
126
236
 
127
- function ContextBar(props: { api: TuiPluginApi; sessionID: string }) {
128
- const messages = createMemo((): any[] => {
129
- try { return (props.api.state.session as any).messages(props.sessionID) ?? [] }
130
- catch { return [] }
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
- const usage = createMemo(() => {
134
- const msgs = messages()
135
- const lastAssistant = [...msgs].reverse().find((m: any) => {
136
- const role = m?.role ?? m?.info?.role
137
- return role === 'assistant' && safeNum(m?.tokens?.output) > 0
138
- })
139
- if (!lastAssistant) return null
140
-
141
- const tokens = msgTokens(lastAssistant)
142
- const pid = lastAssistant?.providerID ?? lastAssistant?.info?.providerID
143
- const mid = lastAssistant?.modelID ?? lastAssistant?.info?.modelID
144
- const provider = pid
145
- ? ((props.api.state as any).provider ?? []).find((p: any) => p.id === pid)
146
- : null
147
- const ctxLimit = safeNum(provider?.models?.[mid ?? '']?.limit?.context)
148
- const limit = ctxLimit > 0 ? ctxLimit : 200000
149
- return { tokens, limit, percent: ctxLimit > 0 ? Math.round((tokens / ctxLimit) * 100) : 0 }
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
- const totalCost = createMemo(() => {
153
- const state = ((props.api.state as any).session)?.get?.(props.sessionID)
154
- const fromState = msgCost(state)
155
- if (fromState > 0) return fromState
156
- return messages().reduce((sum: number, m: any) => sum + msgCost(m), 0)
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
- const theme = () => props.api.theme.current
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
- <box flexDirection="column">
170
- <box flexDirection="row" gap={1}>
171
- <text fg={c.text} attributes={{ bold: true }}>Context</text>
172
- <text fg={barColor}>{buildBar(pct)}</text>
173
- <text fg={barColor}>{`${pct}%`}</text>
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
- <text fg={c.textMuted}>
176
- {`${fmtInt(u().tokens)} / ${fmtInt(u().limit)} tok${costStr ? ` ${costStr}` : ''}`}
177
- </text>
178
- </box>
869
+ </Show>
179
870
  )
180
- }}
181
- </Show>
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 { /* ignore */ }
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': return '\u25cf ' // ● — running
201
- case 'retry': return '\u26a0 ' // ⚠ — needs attention
202
- default: return '' // idle/unknown — no icon
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 { /* events API not available in this runtime */ }
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 = (props.api.event as any)
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.created', () => setMemoryCount((c) => (c !== null ? c + 1 : 1))),
283
- )
284
- cleanup.push(
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 { /* memory events not available */ }
1011
+ } catch {
1012
+ /* memory events not available */
1013
+ }
289
1014
 
290
- onCleanup(() => cleanup.forEach((fn) => fn()))
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={{ bold: true }}>
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
- {/* ── Context Bar (always visible) ── */}
319
- <ContextBar api={props.api} sessionID={props.sessionID} />
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={{ bold: true }}>
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={<box marginLeft={1}><text fg={theme().textMuted}>No recent sessions</text></box>}
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={{ bold: true }}>
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 onMouseDown={(e) => {
355
- e.stopPropagation()
356
- try {
357
- const cmdApi = (props.api as any).command
358
- const cmdName = cmd.name.replace('/', '')
359
- if (cmdApi?.trigger?.(cmdName)) return
360
- } catch { /* fall through to toast */ }
361
- props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
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={{ bold: true }}>
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={{ bold: true }}>
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 when={configSummary()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>No config data</text></box>}>
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 fg={theme().textMuted}>{`MCP: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
406
- <text fg={theme().textMuted}>{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
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: {