@brimveyn/aimux 1.7.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +27 -15
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +286 -0
  4. package/src/app-runtime/auto-commit-ref.ts +20 -0
  5. package/src/app-runtime/backend-attach-runtime.ts +3 -1
  6. package/src/app-runtime/session-actions.ts +7 -2
  7. package/src/app-runtime/side-effects.ts +99 -1
  8. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  9. package/src/app-runtime/use-terminal-resize.ts +20 -12
  10. package/src/app.tsx +59 -22
  11. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  12. package/src/auto-commit/headless-commands.ts +40 -0
  13. package/src/auto-commit/output-parser.ts +21 -0
  14. package/src/auto-commit/prompt-loader.ts +33 -0
  15. package/src/auto-commit/staging-mode.ts +5 -0
  16. package/src/auto-commit/strip-ansi.ts +13 -0
  17. package/src/auto-commit/suggestion-runner.ts +55 -0
  18. package/src/auto-commit/working-tree-hash.ts +24 -0
  19. package/src/config.ts +24 -0
  20. package/src/daemon/session-registry.ts +1 -0
  21. package/src/index.tsx +1 -1
  22. package/src/input/keymap/help-entries.ts +4 -4
  23. package/src/input/modes/bridge.ts +6 -0
  24. package/src/input/modes/transitions.ts +3 -1
  25. package/src/input/modes/types.ts +4 -0
  26. package/src/ipc/manager-protocol.ts +2 -2
  27. package/src/ipc/protocol.ts +2 -8
  28. package/src/pty/assistant-status-detector.ts +1 -1
  29. package/src/pty/terminal-snapshot.ts +38 -4
  30. package/src/services/ai-usage/adapters/claude.ts +139 -0
  31. package/src/services/ai-usage/adapters/codex.ts +191 -0
  32. package/src/services/ai-usage/provider.ts +84 -0
  33. package/src/services/ai-usage/spawn.ts +49 -0
  34. package/src/services/ai-usage/types.ts +20 -0
  35. package/src/session-backend/local-session-backend.ts +10 -2
  36. package/src/state/ai-usage-store.ts +29 -0
  37. package/src/state/reducers/auto-commit-state.ts +59 -0
  38. package/src/state/reducers/modal-state.ts +106 -2
  39. package/src/state/reducers/session-state.ts +26 -14
  40. package/src/state/session-persistence.ts +14 -5
  41. package/src/state/store.ts +24 -14
  42. package/src/state/types.ts +55 -2
  43. package/src/state/workspace-save.ts +4 -0
  44. package/src/ui/ai-usage/controller.ts +35 -0
  45. package/src/ui/components/ai-usage-indicator.tsx +131 -0
  46. package/src/ui/components/ai-usage-popover.tsx +152 -0
  47. package/src/ui/components/create-session-modal.tsx +2 -2
  48. package/src/ui/components/git-commit-modal.tsx +167 -18
  49. package/src/ui/components/session-bar.tsx +2 -2
  50. package/src/ui/components/session-picker-modal.tsx +4 -4
  51. package/src/ui/components/sidebar.tsx +3 -1
  52. package/src/ui/components/status-bar.tsx +2 -0
  53. package/src/ui/components/terminal-pane.tsx +18 -3
  54. package/src/ui/root.tsx +13 -2
  55. package/src/ui/status-bar-model.ts +1 -1
package/src/index.tsx CHANGED
@@ -52,7 +52,7 @@ if (command === 'terminal-manager') {
52
52
 
53
53
  if (command === '--help' || command === '-h') {
54
54
  process.stdout.write(
55
- 'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n aimux restart-terminal-manager Restart terminal-manager (kills live sessions)\n\n'
55
+ 'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n aimux restart-terminal-manager Restart terminal-manager (kills live workspaces)\n\n'
56
56
  )
57
57
  process.exit(0)
58
58
  }
@@ -14,10 +14,10 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
14
14
  { label: 'Git commit', modeId: 'modal.git-commit' },
15
15
  { label: 'New tab', modeId: 'modal.new-tab.command-edit' },
16
16
  { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
17
- { label: 'Session picker', modeId: 'modal.session-picker.filtering' },
18
- { label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
19
- { label: 'Session name', modeId: 'modal.session-name' },
20
- { label: 'Create session', modeId: 'modal.create-session' },
17
+ { label: 'Workspace picker', modeId: 'modal.session-picker.filtering' },
18
+ { label: 'Workspace picker — filter', modeId: 'modal.session-picker.filtering' },
19
+ { label: 'Workspace name', modeId: 'modal.session-name' },
20
+ { label: 'Create workspace', modeId: 'modal.create-session' },
21
21
  { label: 'Rename tab', modeId: 'modal.rename-tab' },
22
22
  { label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
23
23
  { label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
@@ -43,6 +43,9 @@ export function deriveModeId(state: AppState): ModeId {
43
43
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
44
44
  return 'modal.new-tab.editing-command'
45
45
  }
46
+ if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
47
+ return 'modal.git-commit.confirm'
48
+ }
46
49
  const modalType = state.modal.type
47
50
  const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
48
51
  if (commandEditMode) {
@@ -53,6 +56,9 @@ export function deriveModeId(state: AppState): ModeId {
53
56
  }
54
57
 
55
58
  if (state.focusMode === 'modal') {
59
+ if (state.modal.type === 'git-commit' && state.modal.stage === 'generating') {
60
+ return 'modal.git-commit.generating'
61
+ }
56
62
  const modalType = state.modal.type
57
63
  const modalMode = modalType ? MODAL_MODE_IDS[modalType] : undefined
58
64
  if (modalMode) {
@@ -3,7 +3,9 @@ import type { ModeId } from './types'
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
5
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
- 'modal.git-commit': ['git-mode'],
6
+ 'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
7
+ 'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
8
+ 'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
7
9
  'modal.help.filtering': ['navigation'],
8
10
  'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
9
11
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
@@ -18,6 +18,8 @@ export type ModeId =
18
18
  | 'modal.help.filtering'
19
19
  | 'modal.split-picker'
20
20
  | 'modal.git-commit'
21
+ | 'modal.git-commit.confirm'
22
+ | 'modal.git-commit.generating'
21
23
  | 'modal.update-available'
22
24
 
23
25
  export type SideEffect =
@@ -55,6 +57,8 @@ export type SideEffect =
55
57
  | { type: 'git-restore'; path: string }
56
58
  | { type: 'git-rm'; path: string }
57
59
  | { type: 'git-commit'; title: string; body: string }
60
+ | { type: 'git-commit-auto'; title: string; body: string }
61
+ | { type: 'generate-auto-commit-now'; sessionId: string }
58
62
  | { type: 'git-push' }
59
63
  | { type: 'confirm-update-selection' }
60
64
  | { type: 'switch-session-by-index'; index: number }
@@ -14,8 +14,8 @@ import {
14
14
  negotiateProtocolVersion,
15
15
  } from './protocol'
16
16
 
17
- export const MANAGER_PROTOCOL_MIN_VERSION = 2
18
- export const MANAGER_PROTOCOL_VERSION = 2
17
+ export const MANAGER_PROTOCOL_MIN_VERSION = 3
18
+ export const MANAGER_PROTOCOL_VERSION = 3
19
19
 
20
20
  export interface ManagerHelloRequest {
21
21
  minVersion: number
@@ -10,14 +10,8 @@ import type {
10
10
 
11
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
12
 
13
- // v4 widens sessionStatus from a single status enum to independent
14
- // {working, waiting} flags so a chip can show both at once.
15
- // v5 folds initial tab activities and session statuses into attachResult so
16
- // the client applies them atomically with tab creation — previously they
17
- // arrived as separate events and could lose to the unknown-tab no-op in
18
- // the reducer.
19
- export const IPC_PROTOCOL_MIN_VERSION = 6
20
- export const IPC_PROTOCOL_VERSION = 6
13
+ export const IPC_PROTOCOL_MIN_VERSION = 7
14
+ export const IPC_PROTOCOL_VERSION = 7
21
15
 
22
16
  export interface ProtocolHelloRequest {
23
17
  minVersion: number
@@ -85,10 +85,10 @@ export class AssistantStatusDetector {
85
85
  }
86
86
 
87
87
  function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
+ const lines = viewport.tailLines ?? viewport.lines
88
89
  // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
89
90
  // often leave the last rows blank, putting their status bar higher up.
90
91
  // Skip trailing blank rows before taking the last `lineCount`.
91
- const lines = viewport.lines
92
92
  let end = lines.length
93
93
  while (end > 0) {
94
94
  const line = lines[end - 1]
@@ -4,6 +4,8 @@ import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/type
4
4
 
5
5
  import { getCurrentTheme } from '../ui/theme'
6
6
 
7
+ const SNAPSHOT_TAIL_LINE_COUNT = 10
8
+
7
9
  const ANSI_PALETTE = [
8
10
  '#000000',
9
11
  '#cd0000',
@@ -142,9 +144,11 @@ function buildLine(
142
144
  export function snapshotTerminal(terminal: Terminal, cursorVisible = true): TerminalSnapshot {
143
145
  const buffer = terminal.buffer.active
144
146
  const startLine = buffer.viewportY
147
+ const tailStartLine = Math.max(0, buffer.baseY + terminal.rows - SNAPSHOT_TAIL_LINE_COUNT)
145
148
  const cursorRow = buffer.cursorY
146
149
  const cursorColumn = Math.min(buffer.cursorX, Math.max(terminal.cols - 1, 0))
147
150
  const lines: TerminalLine[] = []
151
+ const tailLines: TerminalLine[] = []
148
152
 
149
153
  for (let row = 0; row < terminal.rows; row += 1) {
150
154
  lines.push(
@@ -152,10 +156,27 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
152
156
  )
153
157
  }
154
158
 
159
+ for (
160
+ let lineIndex = tailStartLine;
161
+ lineIndex <= buffer.baseY + terminal.rows - 1;
162
+ lineIndex += 1
163
+ ) {
164
+ const relativeCursorRow = lineIndex - buffer.baseY
165
+ tailLines.push(
166
+ buildLine(
167
+ terminal,
168
+ lineIndex,
169
+ relativeCursorRow === cursorRow ? cursorColumn : null,
170
+ cursorVisible
171
+ )
172
+ )
173
+ }
174
+
155
175
  return {
156
176
  baseY: buffer.baseY,
157
177
  cursorVisible,
158
178
  lines,
179
+ tailLines,
159
180
  viewportY: buffer.viewportY,
160
181
  }
161
182
  }
@@ -195,6 +216,13 @@ export function areTerminalSnapshotsEqual(
195
216
  return false
196
217
  }
197
218
 
219
+ const leftTailLines = left.tailLines ?? []
220
+ const rightTailLines = right.tailLines ?? []
221
+
222
+ if (leftTailLines.length !== rightTailLines.length) {
223
+ return false
224
+ }
225
+
198
226
  if (
199
227
  left.viewportY !== right.viewportY ||
200
228
  left.baseY !== right.baseY ||
@@ -203,8 +231,14 @@ export function areTerminalSnapshotsEqual(
203
231
  return false
204
232
  }
205
233
 
206
- return left.lines.every((line, index) => {
207
- const other = right.lines[index]
208
- return other ? areLinesEqual(line, other) : false
209
- })
234
+ return (
235
+ left.lines.every((line, index) => {
236
+ const other = right.lines[index]
237
+ return other ? areLinesEqual(line, other) : false
238
+ }) &&
239
+ leftTailLines.every((line, index) => {
240
+ const other = rightTailLines[index]
241
+ return other ? areLinesEqual(line, other) : false
242
+ })
243
+ )
210
244
  }
@@ -0,0 +1,139 @@
1
+ import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
+
3
+ import type { UsageSnapshot } from '../types'
4
+
5
+ import { runCli } from '../spawn'
6
+
7
+ interface ClaudeOAuthCreds {
8
+ accessToken: string
9
+ expiresAt?: number
10
+ }
11
+
12
+ interface ClaudeKeychainPayload {
13
+ claudeAiOauth?: {
14
+ accessToken?: string
15
+ expiresAt?: number
16
+ refreshToken?: string
17
+ }
18
+ }
19
+
20
+ interface UsageWindow {
21
+ utilization?: number
22
+ resets_at?: string
23
+ }
24
+
25
+ interface ClaudeUsageResponse {
26
+ five_hour?: UsageWindow
27
+ seven_day?: UsageWindow
28
+ seven_day_sonnet?: UsageWindow
29
+ seven_day_opus?: UsageWindow
30
+ extra_usage?: UsageWindow & { spent_usd?: number; limit_usd?: number }
31
+ }
32
+
33
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
34
+ const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
35
+ const FETCH_TIMEOUT_MS = 15_000
36
+
37
+ let cachedCreds: ClaudeOAuthCreds | null = null
38
+ const CREDS_EXPIRY_BUFFER_MS = 60_000
39
+
40
+ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
41
+ const now = Date.now()
42
+ if (
43
+ cachedCreds &&
44
+ typeof cachedCreds.expiresAt === 'number' &&
45
+ cachedCreds.expiresAt - CREDS_EXPIRY_BUFFER_MS > now
46
+ ) {
47
+ return cachedCreds
48
+ }
49
+
50
+ if (process.platform !== 'darwin') {
51
+ throw new Error('claude usage requires macOS keychain (darwin only)')
52
+ }
53
+ const result = await runCli('security', [
54
+ 'find-generic-password',
55
+ '-s',
56
+ 'Claude Code-credentials',
57
+ '-w',
58
+ ])
59
+ if (!result.ok) {
60
+ throw new Error(`keychain read failed — run \`claude\` to sign in`)
61
+ }
62
+ const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
63
+ const access = parsed.claudeAiOauth?.accessToken
64
+ if (!access) {
65
+ throw new Error('no accessToken in Claude Code keychain')
66
+ }
67
+ cachedCreds = { accessToken: access, expiresAt: parsed.claudeAiOauth?.expiresAt }
68
+ return cachedCreds
69
+ }
70
+
71
+ function formatRemainingFromIso(iso: string | undefined): string | null {
72
+ if (!iso) return null
73
+ const ms = new Date(iso).getTime() - Date.now()
74
+ if (ms <= 0) return null
75
+ const totalMin = Math.round(ms / 60_000)
76
+ const h = Math.floor(totalMin / 60)
77
+ const m = totalMin % 60
78
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
79
+ }
80
+
81
+ export async function fetchClaudeUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
82
+ const now = new Date().toISOString()
83
+ const base: UsageSnapshot = {
84
+ burnRatePerHour: null,
85
+ costUSD: null,
86
+ lastUpdated: now,
87
+ percent: null,
88
+ resetAt: null,
89
+ timeRemaining: null,
90
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
91
+ tool: 'claude',
92
+ }
93
+
94
+ try {
95
+ const creds = await readClaudeCreds()
96
+
97
+ const controller = new AbortController()
98
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
99
+ let response: Response
100
+ try {
101
+ response = await fetch(USAGE_URL, {
102
+ headers: {
103
+ 'anthropic-beta': OAUTH_BETA_HEADER,
104
+ 'Authorization': `Bearer ${creds.accessToken}`,
105
+ 'Content-Type': 'application/json',
106
+ },
107
+ signal: controller.signal,
108
+ })
109
+ } finally {
110
+ clearTimeout(timer)
111
+ }
112
+
113
+ if (response.status === 401 || response.status === 403) {
114
+ cachedCreds = null
115
+ return { ...base, error: 'claude oauth expired — run `claude` to re-auth' }
116
+ }
117
+ if (!response.ok) {
118
+ return { ...base, error: `claude api ${response.status}` }
119
+ }
120
+
121
+ const parsed = (await response.json()) as ClaudeUsageResponse
122
+ const fiveHour = parsed.five_hour
123
+ const utilization = typeof fiveHour?.utilization === 'number' ? fiveHour.utilization : null
124
+ const percent = utilization === null ? null : Math.max(0, Math.min(100, utilization))
125
+
126
+ return {
127
+ ...base,
128
+ percent,
129
+ resetAt: fiveHour?.resets_at ?? null,
130
+ timeRemaining: formatRemainingFromIso(fiveHour?.resets_at),
131
+ tool: 'claude',
132
+ }
133
+ } catch (error) {
134
+ return {
135
+ ...base,
136
+ error: error instanceof Error ? error.message : String(error),
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,191 @@
1
+ import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import type { UsageSnapshot } from '../types'
8
+
9
+ interface CodexAuthFile {
10
+ tokens?: {
11
+ access_token?: string
12
+ account_id?: string
13
+ }
14
+ }
15
+
16
+ interface WindowSnapshot {
17
+ used_percent?: number
18
+ reset_at?: number
19
+ limit_window_seconds?: number
20
+ }
21
+
22
+ interface CodexUsageResponse {
23
+ plan_type?: string
24
+ rate_limit?: {
25
+ primary_window?: WindowSnapshot | null
26
+ secondary_window?: WindowSnapshot | null
27
+ }
28
+ credits?: {
29
+ has_credits?: boolean
30
+ unlimited?: boolean
31
+ balance?: number | string | null
32
+ }
33
+ }
34
+
35
+ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
36
+ const USAGE_PATH = '/wham/usage'
37
+ const AUTH_TIMEOUT_MS = 15_000
38
+
39
+ function codexHome(): string {
40
+ const env = process.env.CODEX_HOME?.trim()
41
+ if (env) return env
42
+ return join(homedir(), '.codex')
43
+ }
44
+
45
+ function parseChatGPTBaseFromConfig(contents: string): string | null {
46
+ for (const rawLine of contents.split(/\r?\n/)) {
47
+ const line = rawLine.split('#', 1)[0]?.trim() ?? ''
48
+ if (!line) continue
49
+ const eq = line.indexOf('=')
50
+ if (eq < 0) continue
51
+ const key = line.slice(0, eq).trim()
52
+ if (key !== 'chatgpt_base_url') continue
53
+ let value = line.slice(eq + 1).trim()
54
+ if (
55
+ (value.startsWith('"') && value.endsWith('"')) ||
56
+ (value.startsWith("'") && value.endsWith("'"))
57
+ ) {
58
+ value = value.slice(1, -1)
59
+ }
60
+ return value.trim()
61
+ }
62
+ return null
63
+ }
64
+
65
+ function normalizeBase(value: string): string {
66
+ let trimmed = value.trim() || DEFAULT_CHATGPT_BASE
67
+ while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1)
68
+ if (
69
+ (trimmed.startsWith('https://chatgpt.com') || trimmed.startsWith('https://chat.openai.com')) &&
70
+ !trimmed.includes('/backend-api')
71
+ ) {
72
+ trimmed += '/backend-api'
73
+ }
74
+ return trimmed
75
+ }
76
+
77
+ async function resolveUsageURL(): Promise<string> {
78
+ let base = DEFAULT_CHATGPT_BASE
79
+ try {
80
+ const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
81
+ const parsed = parseChatGPTBaseFromConfig(contents)
82
+ if (parsed) base = parsed
83
+ } catch {
84
+ // no config.toml or unreadable — fall back to default
85
+ }
86
+ const normalized = normalizeBase(base)
87
+ const path = normalized.includes('/backend-api') ? USAGE_PATH : '/api/codex/usage'
88
+ return normalized + path
89
+ }
90
+
91
+ async function loadAuth(): Promise<{ accessToken: string; accountId: string | null }> {
92
+ const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
93
+ const parsed = JSON.parse(raw) as CodexAuthFile
94
+ const accessToken = parsed.tokens?.access_token
95
+ if (!accessToken) {
96
+ throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
97
+ }
98
+ return {
99
+ accessToken,
100
+ accountId: parsed.tokens?.account_id ?? null,
101
+ }
102
+ }
103
+
104
+ function formatRemainingFromReset(resetAtSeconds: number | undefined): string | null {
105
+ if (!resetAtSeconds) return null
106
+ const diffMs = resetAtSeconds * 1000 - Date.now()
107
+ if (diffMs <= 0) return null
108
+ const totalMin = Math.round(diffMs / 60_000)
109
+ const h = Math.floor(totalMin / 60)
110
+ const m = totalMin % 60
111
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
112
+ }
113
+
114
+ function pickPrimaryWindow(rate: CodexUsageResponse['rate_limit']): WindowSnapshot | null {
115
+ if (!rate) return null
116
+ // Primary = 5h session window. Pick the one closest to 300 minutes (18000s),
117
+ // falling back to whichever is defined.
118
+ const windows = [rate.primary_window, rate.secondary_window].filter(
119
+ (w): w is WindowSnapshot => !!w
120
+ )
121
+ if (windows.length === 0) return null
122
+ const sessionWindow = windows.find((w) => w.limit_window_seconds === 18_000)
123
+ return sessionWindow ?? windows[0] ?? null
124
+ }
125
+
126
+ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
127
+ const now = new Date().toISOString()
128
+ const base: UsageSnapshot = {
129
+ burnRatePerHour: null,
130
+ costUSD: null,
131
+ lastUpdated: now,
132
+ percent: null,
133
+ resetAt: null,
134
+ timeRemaining: null,
135
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
136
+ tool: 'codex',
137
+ }
138
+
139
+ try {
140
+ const { accessToken, accountId } = await loadAuth()
141
+ const url = await resolveUsageURL()
142
+
143
+ const headers: Record<string, string> = {
144
+ 'Accept': 'application/json',
145
+ 'Authorization': `Bearer ${accessToken}`,
146
+ 'User-Agent': 'aimux',
147
+ }
148
+ if (accountId) headers['ChatGPT-Account-Id'] = accountId
149
+
150
+ const controller = new AbortController()
151
+ const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
152
+ let response: Response
153
+ try {
154
+ response = await fetch(url, { headers, signal: controller.signal })
155
+ } finally {
156
+ clearTimeout(timer)
157
+ }
158
+
159
+ if (response.status === 401 || response.status === 403) {
160
+ return { ...base, error: 'codex oauth expired — run `codex` to re-auth' }
161
+ }
162
+ if (!response.ok) {
163
+ return { ...base, error: `codex api ${response.status}` }
164
+ }
165
+
166
+ const parsed = (await response.json()) as CodexUsageResponse
167
+ const window = pickPrimaryWindow(parsed.rate_limit)
168
+ if (!window) {
169
+ return { ...base, error: 'no rate_limit data' }
170
+ }
171
+
172
+ const percent =
173
+ typeof window.used_percent === 'number'
174
+ ? Math.max(0, Math.min(100, window.used_percent))
175
+ : null
176
+ const resetAt = window.reset_at ? new Date(window.reset_at * 1000).toISOString() : null
177
+
178
+ return {
179
+ ...base,
180
+ percent,
181
+ resetAt,
182
+ timeRemaining: formatRemainingFromReset(window.reset_at),
183
+ tool: 'codex',
184
+ }
185
+ } catch (error) {
186
+ return {
187
+ ...base,
188
+ error: error instanceof Error ? error.message : String(error),
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,84 @@
1
+ import type { AIUsageTool, AIUsageToolConfig } from '@brimveyn/aimux-config'
2
+
3
+ import type { UsageSnapshot } from './types'
4
+
5
+ import { fetchClaudeUsage } from './adapters/claude'
6
+ import { fetchCodexUsage } from './adapters/codex'
7
+
8
+ const DEFAULT_POLL_SECONDS = 60
9
+ const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
10
+
11
+ export interface AIUsageServiceHandle {
12
+ stop: () => void
13
+ refresh: () => void
14
+ }
15
+
16
+ async function fetchFor(tool: AIUsageTool, config: AIUsageToolConfig): Promise<UsageSnapshot> {
17
+ switch (tool) {
18
+ case 'claude':
19
+ return fetchClaudeUsage(config)
20
+ case 'codex':
21
+ return fetchCodexUsage(config)
22
+ }
23
+ }
24
+
25
+ export function startAIUsageService(
26
+ config: AIUsageToolConfig,
27
+ onUpdate: (snap: UsageSnapshot) => void
28
+ ): AIUsageServiceHandle {
29
+ const tools = config.tools && config.tools.length > 0 ? config.tools : DEFAULT_TOOLS
30
+ const pollMs = Math.max(5, config.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000
31
+
32
+ let stopped = false
33
+ let timer: ReturnType<typeof setTimeout> | null = null
34
+
35
+ const tick = async (): Promise<void> => {
36
+ if (stopped) return
37
+ const results = await Promise.allSettled(tools.map((t) => fetchFor(t, config)))
38
+ if (stopped) return
39
+ for (let i = 0; i < results.length; i++) {
40
+ const result = results[i]
41
+ const tool = tools[i]
42
+ if (!result || !tool) continue
43
+ if (result.status === 'fulfilled') {
44
+ onUpdate(result.value)
45
+ } else {
46
+ onUpdate({
47
+ burnRatePerHour: null,
48
+ costUSD: null,
49
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
50
+ lastUpdated: new Date().toISOString(),
51
+ percent: null,
52
+ resetAt: null,
53
+ timeRemaining: null,
54
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
55
+ tool,
56
+ })
57
+ }
58
+ }
59
+ if (!stopped) {
60
+ timer = setTimeout(() => {
61
+ void tick()
62
+ }, pollMs)
63
+ }
64
+ }
65
+
66
+ void tick()
67
+
68
+ return {
69
+ refresh: () => {
70
+ if (timer) {
71
+ clearTimeout(timer)
72
+ timer = null
73
+ }
74
+ void tick()
75
+ },
76
+ stop: () => {
77
+ stopped = true
78
+ if (timer) {
79
+ clearTimeout(timer)
80
+ timer = null
81
+ }
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,49 @@
1
+ interface CliResult {
2
+ ok: boolean
3
+ stdout: string
4
+ stderr: string
5
+ error?: string
6
+ }
7
+
8
+ const DEFAULT_TIMEOUT_MS = 15_000
9
+
10
+ export async function runCli(
11
+ command: string,
12
+ args: string[],
13
+ timeoutMs: number = DEFAULT_TIMEOUT_MS
14
+ ): Promise<CliResult> {
15
+ const proc = Bun.spawn([command, ...args], {
16
+ stderr: 'pipe',
17
+ stdin: 'ignore',
18
+ stdout: 'pipe',
19
+ })
20
+
21
+ const timeout = setTimeout(() => {
22
+ try {
23
+ proc.kill()
24
+ } catch {
25
+ // process already gone
26
+ }
27
+ }, timeoutMs)
28
+
29
+ try {
30
+ const [stdout, stderr, exitCode] = await Promise.all([
31
+ new Response(proc.stdout).text(),
32
+ new Response(proc.stderr).text(),
33
+ proc.exited,
34
+ ])
35
+
36
+ if (exitCode !== 0) {
37
+ return {
38
+ error: `${command} exit ${exitCode}: ${stderr.trim().slice(0, 200)}`,
39
+ ok: false,
40
+ stderr,
41
+ stdout,
42
+ }
43
+ }
44
+
45
+ return { ok: true, stderr, stdout }
46
+ } finally {
47
+ clearTimeout(timeout)
48
+ }
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ export type { AIUsageTool }
4
+
5
+ export interface UsageSnapshot {
6
+ tool: AIUsageTool
7
+ percent: number | null
8
+ tokens: {
9
+ input: number
10
+ output: number
11
+ cache: number
12
+ total: number
13
+ }
14
+ costUSD: number | null
15
+ resetAt: string | null
16
+ timeRemaining: string | null
17
+ burnRatePerHour: number | null
18
+ lastUpdated: string
19
+ error?: string
20
+ }