@brimveyn/aimux 1.7.4 → 1.9.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 (56) 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 +7 -0
  24. package/src/input/modes/transitions.ts +6 -2
  25. package/src/input/modes/types.ts +5 -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 +215 -0
  31. package/src/services/ai-usage/adapters/codex.ts +242 -0
  32. package/src/services/ai-usage/cache.ts +60 -0
  33. package/src/services/ai-usage/pace.ts +79 -0
  34. package/src/services/ai-usage/provider.ts +107 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +51 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +36 -0
  39. package/src/state/reducers/auto-commit-state.ts +59 -0
  40. package/src/state/reducers/modal-state.ts +119 -2
  41. package/src/state/reducers/session-state.ts +26 -14
  42. package/src/state/session-persistence.ts +14 -5
  43. package/src/state/store.ts +24 -14
  44. package/src/state/types.ts +62 -2
  45. package/src/state/workspace-save.ts +4 -0
  46. package/src/ui/components/ai-usage-indicator.tsx +163 -0
  47. package/src/ui/components/ai-usage-modal.tsx +186 -0
  48. package/src/ui/components/create-session-modal.tsx +2 -2
  49. package/src/ui/components/git-commit-modal.tsx +167 -18
  50. package/src/ui/components/session-bar.tsx +2 -2
  51. package/src/ui/components/session-picker-modal.tsx +4 -4
  52. package/src/ui/components/sidebar.tsx +3 -1
  53. package/src/ui/components/status-bar.tsx +2 -0
  54. package/src/ui/components/terminal-pane.tsx +18 -3
  55. package/src/ui/root.tsx +14 -2
  56. 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' },
@@ -24,6 +24,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
24
24
  }
25
25
 
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
+ 'ai-usage': 'modal.ai-usage',
27
28
  'update-available': 'modal.update-available',
28
29
  }
29
30
 
@@ -43,6 +44,9 @@ export function deriveModeId(state: AppState): ModeId {
43
44
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
44
45
  return 'modal.new-tab.editing-command'
45
46
  }
47
+ if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
48
+ return 'modal.git-commit.confirm'
49
+ }
46
50
  const modalType = state.modal.type
47
51
  const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
48
52
  if (commandEditMode) {
@@ -53,6 +57,9 @@ export function deriveModeId(state: AppState): ModeId {
53
57
  }
54
58
 
55
59
  if (state.focusMode === 'modal') {
60
+ if (state.modal.type === 'git-commit' && state.modal.stage === 'generating') {
61
+ return 'modal.git-commit.generating'
62
+ }
56
63
  const modalType = state.modal.type
57
64
  const modalMode = modalType ? MODAL_MODE_IDS[modalType] : undefined
58
65
  if (modalMode) {
@@ -2,8 +2,11 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
+ 'modal.ai-usage': ['navigation', 'terminal-input'],
5
6
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
- 'modal.git-commit': ['git-mode'],
7
+ 'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
8
+ 'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
9
+ 'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
7
10
  'modal.help.filtering': ['navigation'],
8
11
  'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
9
12
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
@@ -25,9 +28,10 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
25
28
  'modal.theme-picker.filtering',
26
29
  'modal.rename-tab',
27
30
  'modal.update-available',
31
+ 'modal.ai-usage',
28
32
  'git-mode',
29
33
  ],
30
- 'terminal-input': ['navigation', 'modal.split-picker'],
34
+ 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
31
35
  }
32
36
 
33
37
  export function isValidTransition(from: ModeId, to: ModeId): boolean {
@@ -18,7 +18,10 @@ 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'
24
+ | 'modal.ai-usage'
22
25
 
23
26
  export type SideEffect =
24
27
  | { type: 'quit'; state: AppState }
@@ -55,6 +58,8 @@ export type SideEffect =
55
58
  | { type: 'git-restore'; path: string }
56
59
  | { type: 'git-rm'; path: string }
57
60
  | { type: 'git-commit'; title: string; body: string }
61
+ | { type: 'git-commit-auto'; title: string; body: string }
62
+ | { type: 'generate-auto-commit-now'; sessionId: string }
58
63
  | { type: 'git-push' }
59
64
  | { type: 'confirm-update-selection' }
60
65
  | { 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,215 @@
1
+ import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
+
3
+ import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
4
+
5
+ import { computePace, formatTimeRemaining } from '../pace'
6
+ import { runCli } from '../spawn'
7
+
8
+ interface ClaudeOAuthCreds {
9
+ accessToken: string
10
+ expiresAt?: number
11
+ planTier: string | null
12
+ }
13
+
14
+ interface ClaudeKeychainPayload {
15
+ claudeAiOauth?: {
16
+ accessToken?: string
17
+ expiresAt?: number
18
+ refreshToken?: string
19
+ rateLimitTier?: string
20
+ rate_limit_tier?: string
21
+ subscriptionType?: string
22
+ scopes?: string[]
23
+ }
24
+ }
25
+
26
+ interface UsageWindowPayload {
27
+ utilization?: number
28
+ resets_at?: string
29
+ }
30
+
31
+ interface ClaudeUsageResponse {
32
+ five_hour?: UsageWindowPayload
33
+ seven_day?: UsageWindowPayload
34
+ seven_day_sonnet?: UsageWindowPayload
35
+ seven_day_opus?: UsageWindowPayload
36
+ extra_usage?: UsageWindowPayload & { spent_usd?: number; limit_usd?: number }
37
+ }
38
+
39
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
40
+ const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
41
+ const FETCH_TIMEOUT_MS = 15_000
42
+
43
+ const FIVE_HOUR_SECONDS = 5 * 60 * 60
44
+ const SEVEN_DAY_SECONDS = 7 * 24 * 60 * 60
45
+
46
+ let cachedCreds: ClaudeOAuthCreds | null = null
47
+ const CREDS_EXPIRY_BUFFER_MS = 60_000
48
+
49
+ function normalizePlanTier(raw: string | undefined | null): string | null {
50
+ if (!raw) return null
51
+ const trimmed = raw.trim()
52
+ if (!trimmed) return null
53
+ const lower = trimmed.toLowerCase()
54
+ if (lower.includes('max')) return 'Max'
55
+ if (lower.includes('pro')) return 'Pro'
56
+ if (lower.includes('team')) return 'Team'
57
+ if (lower.includes('enterprise')) return 'Enterprise'
58
+ if (lower.includes('ultra')) return 'Ultra'
59
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
60
+ }
61
+
62
+ function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
63
+ const o = payload.claudeAiOauth
64
+ if (!o) return null
65
+ const raw = o.rateLimitTier ?? o.rate_limit_tier ?? o.subscriptionType
66
+ const normalized = normalizePlanTier(raw)
67
+ if (normalized) return normalized
68
+ if (Array.isArray(o.scopes)) {
69
+ for (const scope of o.scopes) {
70
+ const s = scope.toLowerCase()
71
+ if (s.includes('max')) return 'Max'
72
+ if (s.includes('pro')) return 'Pro'
73
+ if (s.includes('team')) return 'Team'
74
+ if (s.includes('enterprise')) return 'Enterprise'
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
80
+ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
81
+ const now = Date.now()
82
+ if (
83
+ cachedCreds &&
84
+ typeof cachedCreds.expiresAt === 'number' &&
85
+ cachedCreds.expiresAt - CREDS_EXPIRY_BUFFER_MS > now
86
+ ) {
87
+ return cachedCreds
88
+ }
89
+
90
+ if (process.platform !== 'darwin') {
91
+ throw new Error('claude usage requires macOS keychain (darwin only)')
92
+ }
93
+ const result = await runCli('security', [
94
+ 'find-generic-password',
95
+ '-s',
96
+ 'Claude Code-credentials',
97
+ '-w',
98
+ ])
99
+ if (!result.ok) {
100
+ throw new Error(`keychain read failed — run \`claude\` to sign in`)
101
+ }
102
+ const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
103
+ const access = parsed.claudeAiOauth?.accessToken
104
+ if (!access) {
105
+ throw new Error('no accessToken in Claude Code keychain')
106
+ }
107
+ cachedCreds = {
108
+ accessToken: access,
109
+ expiresAt: parsed.claudeAiOauth?.expiresAt,
110
+ planTier: planTierFromPayload(parsed),
111
+ }
112
+ return cachedCreds
113
+ }
114
+
115
+ function buildWindow(
116
+ kind: UsageWindowKind,
117
+ label: string,
118
+ payload: UsageWindowPayload | undefined,
119
+ windowSeconds: number,
120
+ now: number
121
+ ): UsageWindow | null {
122
+ if (!payload) return null
123
+ const util = typeof payload.utilization === 'number' ? payload.utilization : null
124
+ const percent = util === null ? null : Math.max(0, Math.min(100, util))
125
+ const resetAt = payload.resets_at ?? null
126
+ const resetAtMs = resetAt ? new Date(resetAt).getTime() : null
127
+ if (percent === null && !resetAt) return null
128
+ return {
129
+ kind,
130
+ label,
131
+ pace: kind === 'weekly' ? computePace({ now, percent, resetAtMs, windowSeconds }) : null,
132
+ percent,
133
+ resetAt,
134
+ timeRemaining: formatTimeRemaining(resetAtMs, now),
135
+ windowSeconds,
136
+ }
137
+ }
138
+
139
+ export async function fetchClaudeUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
140
+ const nowIso = new Date().toISOString()
141
+ const base: UsageSnapshot = {
142
+ burnRatePerHour: null,
143
+ costUSD: null,
144
+ lastUpdated: nowIso,
145
+ percent: null,
146
+ planTier: null,
147
+ resetAt: null,
148
+ timeRemaining: null,
149
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
150
+ tool: 'claude',
151
+ windows: [],
152
+ }
153
+
154
+ try {
155
+ const creds = await readClaudeCreds()
156
+
157
+ const controller = new AbortController()
158
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
159
+ let response: Response
160
+ try {
161
+ response = await fetch(USAGE_URL, {
162
+ headers: {
163
+ 'anthropic-beta': OAUTH_BETA_HEADER,
164
+ 'Authorization': `Bearer ${creds.accessToken}`,
165
+ 'Content-Type': 'application/json',
166
+ },
167
+ signal: controller.signal,
168
+ })
169
+ } finally {
170
+ clearTimeout(timer)
171
+ }
172
+
173
+ if (response.status === 401 || response.status === 403) {
174
+ cachedCreds = null
175
+ return {
176
+ ...base,
177
+ error: 'claude oauth expired — run `claude` to re-auth',
178
+ planTier: creds.planTier,
179
+ }
180
+ }
181
+ if (!response.ok) {
182
+ return { ...base, error: `claude api ${response.status}`, planTier: creds.planTier }
183
+ }
184
+
185
+ const parsed = (await response.json()) as ClaudeUsageResponse
186
+ const now = Date.now()
187
+
188
+ const windows: UsageWindow[] = []
189
+ const session = buildWindow('session', 'Session', parsed.five_hour, FIVE_HOUR_SECONDS, now)
190
+ if (session) windows.push(session)
191
+ const weekly = buildWindow('weekly', 'Weekly', parsed.seven_day, SEVEN_DAY_SECONDS, now)
192
+ if (weekly) windows.push(weekly)
193
+ const opus = buildWindow('opus', 'Opus', parsed.seven_day_opus, SEVEN_DAY_SECONDS, now)
194
+ if (opus) windows.push(opus)
195
+ const sonnet = buildWindow('sonnet', 'Sonnet', parsed.seven_day_sonnet, SEVEN_DAY_SECONDS, now)
196
+ if (sonnet) windows.push(sonnet)
197
+
198
+ const summary = session ?? weekly
199
+
200
+ return {
201
+ ...base,
202
+ percent: summary?.percent ?? null,
203
+ planTier: creds.planTier,
204
+ resetAt: summary?.resetAt ?? null,
205
+ timeRemaining: summary?.timeRemaining ?? null,
206
+ tool: 'claude',
207
+ windows,
208
+ }
209
+ } catch (error) {
210
+ return {
211
+ ...base,
212
+ error: error instanceof Error ? error.message : String(error),
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,242 @@
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, UsageWindow, UsageWindowKind } from '../types'
8
+
9
+ import { computePace, formatTimeRemaining } from '../pace'
10
+
11
+ interface CodexAuthFile {
12
+ tokens?: {
13
+ access_token?: string
14
+ account_id?: string
15
+ }
16
+ }
17
+
18
+ interface WindowSnapshot {
19
+ used_percent?: number
20
+ reset_at?: number
21
+ limit_window_seconds?: number
22
+ }
23
+
24
+ interface CodexUsageResponse {
25
+ plan_type?: string
26
+ rate_limit?: {
27
+ primary_window?: WindowSnapshot | null
28
+ secondary_window?: WindowSnapshot | null
29
+ }
30
+ credits?: {
31
+ has_credits?: boolean
32
+ unlimited?: boolean
33
+ balance?: number | string | null
34
+ }
35
+ }
36
+
37
+ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
38
+ const USAGE_PATH = '/wham/usage'
39
+ const AUTH_TIMEOUT_MS = 15_000
40
+
41
+ function codexHome(): string {
42
+ const env = process.env.CODEX_HOME?.trim()
43
+ if (env) return env
44
+ return join(homedir(), '.codex')
45
+ }
46
+
47
+ function parseChatGPTBaseFromConfig(contents: string): string | null {
48
+ for (const rawLine of contents.split(/\r?\n/)) {
49
+ const line = rawLine.split('#', 1)[0]?.trim() ?? ''
50
+ if (!line) continue
51
+ const eq = line.indexOf('=')
52
+ if (eq < 0) continue
53
+ const key = line.slice(0, eq).trim()
54
+ if (key !== 'chatgpt_base_url') continue
55
+ let value = line.slice(eq + 1).trim()
56
+ if (
57
+ (value.startsWith('"') && value.endsWith('"')) ||
58
+ (value.startsWith("'") && value.endsWith("'"))
59
+ ) {
60
+ value = value.slice(1, -1)
61
+ }
62
+ return value.trim()
63
+ }
64
+ return null
65
+ }
66
+
67
+ function normalizeBase(value: string): string {
68
+ let trimmed = value.trim() || DEFAULT_CHATGPT_BASE
69
+ while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1)
70
+ if (
71
+ (trimmed.startsWith('https://chatgpt.com') || trimmed.startsWith('https://chat.openai.com')) &&
72
+ !trimmed.includes('/backend-api')
73
+ ) {
74
+ trimmed += '/backend-api'
75
+ }
76
+ return trimmed
77
+ }
78
+
79
+ async function resolveUsageURL(): Promise<string> {
80
+ let base = DEFAULT_CHATGPT_BASE
81
+ try {
82
+ const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
83
+ const parsed = parseChatGPTBaseFromConfig(contents)
84
+ if (parsed) base = parsed
85
+ } catch {
86
+ // no config.toml or unreadable — fall back to default
87
+ }
88
+ const normalized = normalizeBase(base)
89
+ const path = normalized.includes('/backend-api') ? USAGE_PATH : '/api/codex/usage'
90
+ return normalized + path
91
+ }
92
+
93
+ async function loadAuth(): Promise<{ accessToken: string; accountId: string | null }> {
94
+ const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
95
+ const parsed = JSON.parse(raw) as CodexAuthFile
96
+ const accessToken = parsed.tokens?.access_token
97
+ if (!accessToken) {
98
+ throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
99
+ }
100
+ return {
101
+ accessToken,
102
+ accountId: parsed.tokens?.account_id ?? null,
103
+ }
104
+ }
105
+
106
+ function normalizePlanTier(raw: string | undefined | null): string | null {
107
+ if (!raw) return null
108
+ const trimmed = raw.trim()
109
+ if (!trimmed) return null
110
+ const lower = trimmed.toLowerCase()
111
+ if (lower === 'pro') return 'Pro'
112
+ if (lower === 'plus') return 'Plus'
113
+ if (lower === 'free') return 'Free'
114
+ if (lower === 'team') return 'Team'
115
+ if (lower === 'business') return 'Business'
116
+ if (lower === 'enterprise') return 'Enterprise'
117
+ if (lower === 'edu' || lower === 'education') return 'Edu'
118
+ if (lower === 'go') return 'Go'
119
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
120
+ }
121
+
122
+ function buildCodexWindow(
123
+ kind: UsageWindowKind,
124
+ label: string,
125
+ window: WindowSnapshot | null | undefined,
126
+ now: number
127
+ ): UsageWindow | null {
128
+ if (!window) return null
129
+ const percent =
130
+ typeof window.used_percent === 'number' ? Math.max(0, Math.min(100, window.used_percent)) : null
131
+ const resetAtMs = window.reset_at ? window.reset_at * 1000 : null
132
+ const resetAt = resetAtMs ? new Date(resetAtMs).toISOString() : null
133
+ const windowSeconds =
134
+ typeof window.limit_window_seconds === 'number' ? window.limit_window_seconds : null
135
+ if (percent === null && !resetAt) return null
136
+ return {
137
+ kind,
138
+ label,
139
+ pace: computePace({ now, percent, resetAtMs, windowSeconds }),
140
+ percent,
141
+ resetAt,
142
+ timeRemaining: formatTimeRemaining(resetAtMs, now),
143
+ windowSeconds,
144
+ }
145
+ }
146
+
147
+ function orderWindows(
148
+ primary: WindowSnapshot | null | undefined,
149
+ secondary: WindowSnapshot | null | undefined
150
+ ): {
151
+ session: WindowSnapshot | null | undefined
152
+ weekly: WindowSnapshot | null | undefined
153
+ } {
154
+ const SESSION_MIN_SECONDS = 3 * 3600
155
+ const SESSION_MAX_SECONDS = 8 * 3600
156
+ const isSession = (w: WindowSnapshot | null | undefined): boolean => {
157
+ const s = w?.limit_window_seconds
158
+ return typeof s === 'number' && s >= SESSION_MIN_SECONDS && s <= SESSION_MAX_SECONDS
159
+ }
160
+ if (isSession(primary)) return { session: primary, weekly: secondary }
161
+ if (isSession(secondary)) return { session: secondary, weekly: primary }
162
+ return { session: primary, weekly: secondary }
163
+ }
164
+
165
+ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
166
+ const nowIso = new Date().toISOString()
167
+ const base: UsageSnapshot = {
168
+ burnRatePerHour: null,
169
+ costUSD: null,
170
+ lastUpdated: nowIso,
171
+ percent: null,
172
+ planTier: null,
173
+ resetAt: null,
174
+ timeRemaining: null,
175
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
176
+ tool: 'codex',
177
+ windows: [],
178
+ }
179
+
180
+ try {
181
+ const { accessToken, accountId } = await loadAuth()
182
+ const url = await resolveUsageURL()
183
+
184
+ const headers: Record<string, string> = {
185
+ 'Accept': 'application/json',
186
+ 'Authorization': `Bearer ${accessToken}`,
187
+ 'User-Agent': 'aimux',
188
+ }
189
+ if (accountId) headers['ChatGPT-Account-Id'] = accountId
190
+
191
+ const controller = new AbortController()
192
+ const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
193
+ let response: Response
194
+ try {
195
+ response = await fetch(url, { headers, signal: controller.signal })
196
+ } finally {
197
+ clearTimeout(timer)
198
+ }
199
+
200
+ if (response.status === 401 || response.status === 403) {
201
+ return { ...base, error: 'codex oauth expired — run `codex` to re-auth' }
202
+ }
203
+ if (!response.ok) {
204
+ return { ...base, error: `codex api ${response.status}` }
205
+ }
206
+
207
+ const parsed = (await response.json()) as CodexUsageResponse
208
+ const planTier = normalizePlanTier(parsed.plan_type)
209
+ const now = Date.now()
210
+ const { session, weekly } = orderWindows(
211
+ parsed.rate_limit?.primary_window,
212
+ parsed.rate_limit?.secondary_window
213
+ )
214
+
215
+ const windows: UsageWindow[] = []
216
+ const sessionWindow = buildCodexWindow('session', 'Session', session, now)
217
+ if (sessionWindow) windows.push(sessionWindow)
218
+ const weeklyWindow = buildCodexWindow('weekly', 'Weekly', weekly, now)
219
+ if (weeklyWindow) windows.push(weeklyWindow)
220
+
221
+ if (windows.length === 0) {
222
+ return { ...base, error: 'no rate_limit data', planTier }
223
+ }
224
+
225
+ const summary = sessionWindow ?? weeklyWindow
226
+
227
+ return {
228
+ ...base,
229
+ percent: summary?.percent ?? null,
230
+ planTier,
231
+ resetAt: summary?.resetAt ?? null,
232
+ timeRemaining: summary?.timeRemaining ?? null,
233
+ tool: 'codex',
234
+ windows,
235
+ }
236
+ } catch (error) {
237
+ return {
238
+ ...base,
239
+ error: error instanceof Error ? error.message : String(error),
240
+ }
241
+ }
242
+ }