@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.
- package/README.md +27 -15
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +286 -0
- package/src/app-runtime/auto-commit-ref.ts +20 -0
- package/src/app-runtime/backend-attach-runtime.ts +3 -1
- package/src/app-runtime/session-actions.ts +7 -2
- package/src/app-runtime/side-effects.ts +99 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-terminal-resize.ts +20 -12
- package/src/app.tsx +59 -22
- package/src/auto-commit/default-auto-commit-prompt.md +46 -0
- package/src/auto-commit/headless-commands.ts +40 -0
- package/src/auto-commit/output-parser.ts +21 -0
- package/src/auto-commit/prompt-loader.ts +33 -0
- package/src/auto-commit/staging-mode.ts +5 -0
- package/src/auto-commit/strip-ansi.ts +13 -0
- package/src/auto-commit/suggestion-runner.ts +55 -0
- package/src/auto-commit/working-tree-hash.ts +24 -0
- package/src/config.ts +24 -0
- package/src/daemon/session-registry.ts +1 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/help-entries.ts +4 -4
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +6 -2
- package/src/input/modes/types.ts +5 -0
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +2 -8
- package/src/pty/assistant-status-detector.ts +1 -1
- package/src/pty/terminal-snapshot.ts +38 -4
- package/src/services/ai-usage/adapters/claude.ts +215 -0
- package/src/services/ai-usage/adapters/codex.ts +242 -0
- package/src/services/ai-usage/cache.ts +60 -0
- package/src/services/ai-usage/pace.ts +79 -0
- package/src/services/ai-usage/provider.ts +107 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +51 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +36 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/modal-state.ts +119 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +24 -14
- package/src/state/types.ts +62 -2
- package/src/state/workspace-save.ts +4 -0
- package/src/ui/components/ai-usage-indicator.tsx +163 -0
- package/src/ui/components/ai-usage-modal.tsx +186 -0
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/session-bar.tsx +2 -2
- package/src/ui/components/session-picker-modal.tsx +4 -4
- package/src/ui/components/sidebar.tsx +3 -1
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +14 -2
- 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
|
|
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: '
|
|
18
|
-
{ label: '
|
|
19
|
-
{ label: '
|
|
20
|
-
{ label: 'Create
|
|
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 {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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 =
|
|
18
|
-
export const MANAGER_PROTOCOL_VERSION =
|
|
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
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -10,14 +10,8 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
import { isWorkspaceSnapshotV1 } from '../state/validation'
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|