@brimveyn/aimux 1.22.10 → 1.22.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.10",
3
+ "version": "1.22.12",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.0",
69
+ "@brimveyn/aimux-config": "0.10.1",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
package/src/index.tsx CHANGED
@@ -51,6 +51,13 @@ if (command === 'update') {
51
51
  process.exit(await runUpdate())
52
52
  }
53
53
 
54
+ // Spawned detached by `maybeSpawnUsageRollup` below. Walks hundreds of MB of
55
+ // assistant JSONL, so it gets its own process and never shares one with the UI.
56
+ if (command === 'usage-rollup') {
57
+ const { runUsageRollup } = await import('./services/usage-history/rollup')
58
+ process.exit(await runUsageRollup())
59
+ }
60
+
54
61
  const { logDebug } = await import('./debug/input-log')
55
62
  const { getRuntimeProfile } = await import('./daemon/runtime-paths')
56
63
  const runtimeProfile = getRuntimeProfile()
@@ -87,6 +94,7 @@ const [
87
94
  { setHostPalette },
88
95
  { createSessionBackend },
89
96
  { maybeAutoInstallCompletion },
97
+ { maybeSpawnUsageRollup },
90
98
  ] = await Promise.all([
91
99
  import('@opentui/react'),
92
100
  import('./app'),
@@ -95,6 +103,7 @@ const [
95
103
  import('./ui/host-palette'),
96
104
  import('./session-backend/bootstrap'),
97
105
  import('./cli/completion/install'),
106
+ import('./services/usage-history/store'),
98
107
  ])
99
108
  const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
100
109
 
@@ -104,6 +113,12 @@ const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
104
113
  // AIMUX_NO_COMPLETION_INSTALL=1; `aimux doctor` reports what landed where.
105
114
  maybeAutoInstallCompletion()
106
115
 
116
+ // Snapshot today's AI usage before Claude Code prunes the transcripts it came
117
+ // from — roughly a month survives on disk, so the aggregates aimux keeps are
118
+ // the only long-term record. Detached subprocess, at most once per ~day, never
119
+ // blocking. Opt out with AIMUX_NO_USAGE_ROLLUP=1.
120
+ maybeSpawnUsageRollup()
121
+
107
122
  const renderer = await createCliRenderer({
108
123
  autoFocus: true,
109
124
  // Transparent clear color so cells untouched by BoxRenderable paints (e.g.
@@ -6,11 +6,11 @@
6
6
  // Hooks reference: https://code.claude.com/docs/en/hooks.md
7
7
 
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
9
- import { homedir } from 'node:os'
10
9
  import { dirname, join, resolve } from 'node:path'
11
10
  import { fileURLToPath } from 'node:url'
12
11
 
13
12
  import { logDebug } from '../debug/input-log'
13
+ import { claudeHome } from '../platform/assistant-home'
14
14
 
15
15
  const HOOK_EVENTS = [
16
16
  'UserPromptSubmit',
@@ -34,12 +34,8 @@ interface HookGroupEntry {
34
34
  hooks: HookCommandEntry[]
35
35
  }
36
36
 
37
- function claudeDir(): string {
38
- return join(homedir(), '.claude')
39
- }
40
-
41
37
  function settingsFilePath(): string {
42
- return join(claudeDir(), 'settings.json')
38
+ return join(claudeHome(), 'settings.json')
43
39
  }
44
40
 
45
41
  function writeAtomic(target: string, contents: string): void {
@@ -180,7 +176,7 @@ export function ensureClaudeSettingsHooks(): boolean {
180
176
  parsed.hooks = nextHooks
181
177
 
182
178
  try {
183
- mkdirSync(claudeDir(), { recursive: true })
179
+ mkdirSync(claudeHome(), { recursive: true })
184
180
  writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
185
181
  logDebug('claude-hooks-install:wrote', { events: HOOK_EVENTS, path: target, scriptPath })
186
182
  return true
@@ -12,24 +12,20 @@ import {
12
12
  type ThemeMode,
13
13
  } from '@brimveyn/aimux-config'
14
14
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
15
- import { homedir } from 'node:os'
16
15
  import { join } from 'node:path'
17
16
 
18
17
  import { logDebug } from '../debug/input-log'
18
+ import { claudeHome } from '../platform/assistant-home'
19
19
 
20
20
  const THEME_SLUG = 'aimux'
21
21
  const THEME_PREF_VALUE = `custom:${THEME_SLUG}`
22
22
 
23
- function claudeDir(): string {
24
- return join(homedir(), '.claude')
25
- }
26
-
27
23
  function themeFilePath(): string {
28
- return join(claudeDir(), 'themes', `${THEME_SLUG}.json`)
24
+ return join(claudeHome(), 'themes', `${THEME_SLUG}.json`)
29
25
  }
30
26
 
31
27
  function settingsFilePath(): string {
32
- return join(claudeDir(), 'settings.json')
28
+ return join(claudeHome(), 'settings.json')
33
29
  }
34
30
 
35
31
  function writeAtomic(target: string, contents: string): void {
@@ -67,7 +63,7 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
67
63
 
68
64
  const target = themeFilePath()
69
65
  try {
70
- mkdirSync(join(claudeDir(), 'themes'), { recursive: true })
66
+ mkdirSync(join(claudeHome(), 'themes'), { recursive: true })
71
67
  writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
72
68
  } catch (error) {
73
69
  logSyncWarn('write-failed', { err: String(error), path: target })
@@ -110,7 +106,7 @@ export function ensureClaudeSettingsThemePref(): void {
110
106
  parsed.theme = THEME_PREF_VALUE
111
107
 
112
108
  try {
113
- mkdirSync(claudeDir(), { recursive: true })
109
+ mkdirSync(claudeHome(), { recursive: true })
114
110
  writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
115
111
  } catch (error) {
116
112
  logSyncWarn('settings-write-failed', { err: String(error), path: target })
@@ -0,0 +1,25 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Where each assistant CLI keeps its state.
6
+ *
7
+ * Both relocate wholesale through an environment variable, so anything reading
8
+ * or writing inside those directories has to go through here — three hand-rolled
9
+ * copies of `join(homedir(), '.claude')` used to disagree about whether
10
+ * `CLAUDE_CONFIG_DIR` existed, which sent the hook installer and the theme sync
11
+ * to a directory Claude Code was not reading.
12
+ */
13
+ function envHome(variable: string, fallback: string): string {
14
+ const value = process.env[variable]?.trim()
15
+ if (value != null && value !== '') return value
16
+ return join(homedir(), fallback)
17
+ }
18
+
19
+ export function claudeHome(): string {
20
+ return envHome('CLAUDE_CONFIG_DIR', '.claude')
21
+ }
22
+
23
+ export function codexHome(): string {
24
+ return envHome('CODEX_HOME', '.codex')
25
+ }
@@ -61,14 +61,23 @@ async function waitForSocket(socketPath: string): Promise<boolean> {
61
61
  return false
62
62
  }
63
63
 
64
- async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
64
+ /**
65
+ * Re-run this same aimux with another subcommand, detached and unwatched.
66
+ *
67
+ * The entrypoint is resolved from this module rather than `process.argv`, which
68
+ * points at whatever wrapper or shim launched us.
69
+ */
70
+ export function spawnDetachedCommand(command: string): void {
65
71
  Bun.spawn([process.execPath, 'run', ENTRY_POINT, command], {
66
72
  detached: true,
67
73
  stderr: 'ignore',
68
74
  stdin: 'ignore',
69
75
  stdout: 'ignore',
70
76
  }).unref()
77
+ }
71
78
 
79
+ async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
80
+ spawnDetachedCommand(command)
72
81
  return waitForSocket(socketPath)
73
82
  }
74
83
 
@@ -1,11 +1,11 @@
1
1
  import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { readFile } from 'node:fs/promises'
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
 
7
6
  import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
8
7
 
8
+ import { claudeHome } from '../../../platform/assistant-home'
9
9
  import { computePace, formatTimeRemaining } from '../pace'
10
10
  import { runCli } from '../spawn'
11
11
 
@@ -81,15 +81,9 @@ function planTierFromPayload(payload: ClaudeCredentialsPayload): string | null {
81
81
  return null
82
82
  }
83
83
 
84
- function claudeConfigDir(): string {
85
- const env = process.env.CLAUDE_CONFIG_DIR?.trim()
86
- if (env != null && env !== '') return env
87
- return join(homedir(), '.claude')
88
- }
89
-
90
84
  async function readCredsFromFile(): Promise<string | null> {
91
85
  try {
92
- const raw = await readFile(join(claudeConfigDir(), '.credentials.json'), 'utf8')
86
+ const raw = await readFile(join(claudeHome(), '.credentials.json'), 'utf8')
93
87
  return raw.trim() || null
94
88
  } catch {
95
89
  return null
@@ -1,11 +1,11 @@
1
1
  import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { readFile } from 'node:fs/promises'
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
 
7
6
  import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
8
7
 
8
+ import { codexHome } from '../../../platform/assistant-home'
9
9
  import { computePace, formatTimeRemaining } from '../pace'
10
10
 
11
11
  interface CodexAuthFile {
@@ -38,12 +38,6 @@ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
38
38
  const USAGE_PATH = '/wham/usage'
39
39
  const AUTH_TIMEOUT_MS = 15_000
40
40
 
41
- function codexHome(): string {
42
- const env = process.env.CODEX_HOME?.trim()
43
- if (env != null && env !== '') return env
44
- return join(homedir(), '.codex')
45
- }
46
-
47
41
  function parseChatGPTBaseFromConfig(contents: string): string | null {
48
42
  for (const rawLine of contents.split(/\r?\n/)) {
49
43
  const line = rawLine.split('#', 1)[0]?.trim() ?? ''
@@ -0,0 +1,278 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { logDebug } from '../../debug/input-log'
5
+ import { claudeHome, codexHome } from '../../platform/assistant-home'
6
+ import {
7
+ emptyDay,
8
+ localDay,
9
+ saveUsageHistory,
10
+ type UsageDay,
11
+ type UsageDays,
12
+ type UsageTools,
13
+ } from './store'
14
+
15
+ /**
16
+ * Rebuilds the usage aggregates from what the assistants still have on disk.
17
+ *
18
+ * Its own process (`aimux usage-rollup`). Nothing here may be imported from the
19
+ * startup path: it walks hundreds of megabytes of JSONL.
20
+ */
21
+
22
+ const FILE_CONCURRENCY = 8
23
+
24
+ function dayAt(days: UsageDays, date: string): UsageDay {
25
+ const existing = days[date]
26
+ if (existing !== undefined) return existing
27
+ const created = emptyDay()
28
+ days[date] = created
29
+ return created
30
+ }
31
+
32
+ function bump(counts: Record<string, number>, key: string, amount: number): void {
33
+ counts[key] = (counts[key] ?? 0) + amount
34
+ }
35
+
36
+ /** Streamed: the largest transcript here is ~90 MB, which `Bun.file().text()` would materialize whole. */
37
+ export async function readJsonlLines(path: string, onLine: (line: string) => void): Promise<void> {
38
+ const decoder = new TextDecoder()
39
+ let carry = ''
40
+
41
+ for await (const chunk of Bun.file(path).stream()) {
42
+ // `stream: true` holds back a partial UTF-8 sequence at the chunk edge
43
+ // instead of replacing it with U+FFFD and corrupting the line.
44
+ const parts = (carry + decoder.decode(chunk, { stream: true })).split('\n')
45
+ carry = parts.pop() ?? ''
46
+ for (const line of parts) onLine(line)
47
+ }
48
+
49
+ carry += decoder.decode()
50
+ // No trailing newline, or a file being appended to right now — the half-written
51
+ // last line just fails JSON.parse downstream.
52
+ if (carry !== '') onLine(carry)
53
+ }
54
+
55
+ function findJsonl(root: string): string[] {
56
+ try {
57
+ // Recursive: transcripts nest at varying depths and a flat readdir misses half.
58
+ return readdirSync(root, { recursive: true })
59
+ .filter((entry): entry is string => typeof entry === 'string' && entry.endsWith('.jsonl'))
60
+ .map((entry) => join(root, entry))
61
+ } catch {
62
+ return [] // assistant not installed on this machine
63
+ }
64
+ }
65
+
66
+ async function forEachFile(paths: string[], run: (path: string) => Promise<void>): Promise<void> {
67
+ let cursor = 0
68
+ // `cursor++` needs no lock: no await between read and increment, single thread.
69
+ const workers = Array.from({ length: Math.min(FILE_CONCURRENCY, paths.length) }, async () => {
70
+ while (cursor < paths.length) {
71
+ const path = paths[cursor++]
72
+ if (path === undefined) return
73
+ try {
74
+ await run(path)
75
+ } catch {
76
+ // One unreadable transcript must never sink the whole rollup.
77
+ }
78
+ }
79
+ })
80
+ await Promise.all(workers)
81
+ }
82
+
83
+ interface TranscriptUsage {
84
+ cache_creation_input_tokens?: number
85
+ cache_read_input_tokens?: number
86
+ input_tokens?: number
87
+ output_tokens?: number
88
+ }
89
+
90
+ interface TranscriptLine {
91
+ gitBranch?: string
92
+ message?: { id?: string; model?: string; usage?: TranscriptUsage }
93
+ requestId?: string
94
+ timestamp?: string
95
+ }
96
+
97
+ /** `seen` spans the whole run: resume, fork and compaction re-emit the same billed request across files. */
98
+ export function consumeTranscriptLine(line: string, seen: Set<string>, days: UsageDays): void {
99
+ // ~23k of ~126k lines carry usage; the rest hold base64 thinking blobs. A
100
+ // substring test beats parsing them.
101
+ if (!line.includes('"output_tokens"')) return
102
+
103
+ let entry: TranscriptLine
104
+ try {
105
+ entry = JSON.parse(line) as TranscriptLine
106
+ } catch {
107
+ return
108
+ }
109
+
110
+ const usage = entry.message?.usage
111
+ if (usage === undefined || entry.timestamp == null) return
112
+
113
+ // Placeholder messages (API errors, interrupted turns) carry junk usage.
114
+ const model = entry.message?.model
115
+ if (model === '<synthetic>') return
116
+
117
+ const id = entry.message?.id
118
+ const { requestId } = entry
119
+ // Both halves or nothing: without an id a duplicate cannot be proven, and
120
+ // undercounting a stats page is worse than a rare double.
121
+ if (id != null && id !== '' && requestId != null && requestId !== '') {
122
+ const key = `${id}:${requestId}`
123
+ if (seen.has(key)) return
124
+ seen.add(key)
125
+ }
126
+
127
+ const ms = Date.parse(entry.timestamp)
128
+ if (!Number.isFinite(ms)) return
129
+
130
+ const cacheRead = usage.cache_read_input_tokens ?? 0
131
+ const cacheWrite = usage.cache_creation_input_tokens ?? 0
132
+ const input = usage.input_tokens ?? 0
133
+ const output = usage.output_tokens ?? 0
134
+ const total = input + output + cacheRead + cacheWrite
135
+ if (total <= 0) return
136
+
137
+ const day = dayAt(days, localDay(new Date(ms)))
138
+ day.tokens.cacheRead += cacheRead
139
+ day.tokens.cacheWrite += cacheWrite
140
+ day.tokens.input += input
141
+ day.tokens.output += output
142
+ day.tokens.total += total
143
+
144
+ if (model != null && model !== '') bump(day.models, model, total)
145
+ const branch = entry.gitBranch
146
+ if (branch != null && branch !== '') bump(day.branches, branch, total)
147
+ }
148
+
149
+ /** `history.jsonl`, the prompt log — the only source that survives transcript pruning. */
150
+ export function consumeHistoryLine(line: string, days: UsageDays): void {
151
+ if (!line.includes('"timestamp"')) return
152
+
153
+ let entry: { timestamp?: number | string }
154
+ try {
155
+ entry = JSON.parse(line) as { timestamp?: number | string }
156
+ } catch {
157
+ return
158
+ }
159
+
160
+ // A number today, quoted in past releases. `Number()` covers both, where
161
+ // `new Date(quoted)` returns Invalid Date and drops the entry.
162
+ const ms = Number(entry.timestamp)
163
+ if (!Number.isFinite(ms) || ms <= 0) return
164
+
165
+ dayAt(days, localDay(new Date(ms))).prompts += 1
166
+ }
167
+
168
+ interface CodexLine {
169
+ payload?: {
170
+ info?: {
171
+ last_token_usage?: {
172
+ cached_input_tokens?: number
173
+ input_tokens?: number
174
+ output_tokens?: number
175
+ total_tokens?: number
176
+ }
177
+ }
178
+ model?: string
179
+ }
180
+ timestamp?: string
181
+ }
182
+
183
+ export interface CodexFileState {
184
+ model: string | null
185
+ }
186
+
187
+ /** `last_token_usage` is the per-turn delta; the sibling `total_token_usage` is cumulative — summing it overcounts by n²/2. */
188
+ export function consumeCodexLine(line: string, state: CodexFileState, days: UsageDays): void {
189
+ if (!line.includes('"model"') && !line.includes('"last_token_usage"')) return
190
+
191
+ let entry: CodexLine
192
+ try {
193
+ entry = JSON.parse(line) as CodexLine
194
+ } catch {
195
+ return
196
+ }
197
+
198
+ const model = entry.payload?.model
199
+ if (model != null && model !== '') state.model = model
200
+
201
+ const usage = entry.payload?.info?.last_token_usage
202
+ if (usage === undefined || entry.timestamp == null) return
203
+
204
+ const ms = Date.parse(entry.timestamp)
205
+ if (!Number.isFinite(ms)) return
206
+
207
+ const cacheRead = usage.cached_input_tokens ?? 0
208
+ const total = usage.total_tokens ?? 0
209
+ if (total <= 0) return
210
+
211
+ const day = dayAt(days, localDay(new Date(ms)))
212
+ day.tokens.cacheRead += cacheRead
213
+ // cached_input_tokens ⊂ input_tokens, so the cached share is subtracted not added.
214
+ day.tokens.input += Math.max(0, (usage.input_tokens ?? 0) - cacheRead)
215
+ day.tokens.output += usage.output_tokens ?? 0
216
+ day.tokens.total += total
217
+
218
+ if (state.model != null && state.model !== '') bump(day.models, state.model, total)
219
+ }
220
+
221
+ async function collectClaude(): Promise<UsageDays> {
222
+ const days: UsageDays = {}
223
+ const seen = new Set<string>()
224
+ const root = claudeHome()
225
+
226
+ await forEachFile(findJsonl(join(root, 'projects')), async (path) => {
227
+ await readJsonlLines(path, (line) => {
228
+ consumeTranscriptLine(line, seen, days)
229
+ })
230
+ })
231
+
232
+ try {
233
+ await readJsonlLines(join(root, 'history.jsonl'), (line) => {
234
+ consumeHistoryLine(line, days)
235
+ })
236
+ } catch {
237
+ // No prompt log: the heatmap falls back to whatever the transcripts cover.
238
+ }
239
+
240
+ return days
241
+ }
242
+
243
+ async function collectCodex(): Promise<UsageDays> {
244
+ const days: UsageDays = {}
245
+
246
+ await forEachFile(findJsonl(join(codexHome(), 'sessions')), async (path) => {
247
+ const state: CodexFileState = { model: null }
248
+ await readJsonlLines(path, (line) => {
249
+ consumeCodexLine(line, state, days)
250
+ })
251
+ })
252
+
253
+ return days
254
+ }
255
+
256
+ export async function runUsageRollup(): Promise<number> {
257
+ try {
258
+ const [claude, codex] = await Promise.all([collectClaude(), collectCodex()])
259
+
260
+ const fresh: UsageTools = {}
261
+ if (Object.keys(claude).length > 0) fresh.claude = claude
262
+ if (Object.keys(codex).length > 0) fresh.codex = codex
263
+
264
+ // Nothing parsed means every source was missing. Writing would bump the
265
+ // mtime, which schedules the next run, and buy a day of not retrying.
266
+ if (Object.keys(fresh).length === 0) {
267
+ logDebug('usageHistory.rollupEmpty', {})
268
+ return 1
269
+ }
270
+
271
+ return saveUsageHistory(fresh) ? 0 : 1
272
+ } catch (error) {
273
+ logDebug('usageHistory.rollupError', {
274
+ error: error instanceof Error ? error.message : String(error),
275
+ })
276
+ return 1
277
+ }
278
+ }