@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.
- 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 +6 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +4 -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 +139 -0
- package/src/services/ai-usage/adapters/codex.ts +191 -0
- package/src/services/ai-usage/provider.ts +84 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +20 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +29 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/modal-state.ts +106 -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 +55 -2
- package/src/state/workspace-save.ts +4 -0
- package/src/ui/ai-usage/controller.ts +35 -0
- package/src/ui/components/ai-usage-indicator.tsx +131 -0
- package/src/ui/components/ai-usage-popover.tsx +152 -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 +13 -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' },
|
|
@@ -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'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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 =
|
|
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,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
|
+
}
|