@foxden-app/foxclaw 0.3.15 → 0.3.18

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.
@@ -11,14 +11,21 @@ export interface CodexLocalUsageStats {
11
11
  turns: number;
12
12
  usageEvents: number;
13
13
  totals: CodexLocalUsageTotals;
14
- outputSpeed: CodexLocalOutputSpeedStats;
14
+ responseThroughput: CodexLocalResponseThroughputStats;
15
15
  latestSessionMtimeMs: number | null;
16
16
  }
17
- export interface CodexLocalOutputSpeedStats {
18
- samples: number;
19
- outputTokens: number;
17
+ export interface CodexLocalUsageSnapshot {
18
+ computedAtMs: number;
19
+ stats: CodexLocalUsageStats;
20
+ }
21
+ export interface CodexLocalResponseThroughputStats {
22
+ completedTurns: number;
23
+ visibleOutputTokens: number;
20
24
  seconds: number;
21
- latestTokensPerSecond: number | null;
22
- latestSampleAtMs: number | null;
25
+ recentCompletedTurns: number;
26
+ recentVisibleOutputTokens: number;
27
+ recentSeconds: number;
23
28
  }
24
29
  export declare function readCodexLocalUsageStats(codexHome?: string): Promise<CodexLocalUsageStats>;
30
+ export declare function readCodexLocalUsageSnapshot(snapshotPath: string): Promise<CodexLocalUsageSnapshot | null>;
31
+ export declare function writeCodexLocalUsageSnapshot(snapshotPath: string, snapshot: CodexLocalUsageSnapshot): Promise<void>;
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
+ const RECENT_COMPLETED_TURNS = 10;
6
7
  export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
7
8
  const sessionFiles = await listJsonlFiles([
8
9
  path.join(codexHome, 'sessions'),
@@ -13,7 +14,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
13
14
  let sessionsWithUsage = 0;
14
15
  let usageEvents = 0;
15
16
  let latestSessionMtimeMs = null;
16
- const outputSpeed = emptyOutputSpeedStats();
17
+ const completedTurnSamples = [];
17
18
  for (const filePath of sessionFiles) {
18
19
  const stat = await fs.stat(filePath).catch(() => null);
19
20
  if (stat) {
@@ -21,7 +22,7 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
21
22
  }
22
23
  const fileUsage = await readSessionUsage(filePath, turnIds);
23
24
  usageEvents += fileUsage.usageEvents;
24
- addOutputSpeed(outputSpeed, fileUsage.outputSpeed);
25
+ completedTurnSamples.push(...fileUsage.completedTurnSamples);
25
26
  if (fileUsage.totalUsage) {
26
27
  sessionsWithUsage += 1;
27
28
  addUsage(totals, fileUsage.totalUsage);
@@ -33,10 +34,28 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
33
34
  turns: turnIds.size,
34
35
  usageEvents,
35
36
  totals,
36
- outputSpeed,
37
+ responseThroughput: summarizeResponseThroughput(completedTurnSamples),
37
38
  latestSessionMtimeMs,
38
39
  };
39
40
  }
41
+ export async function readCodexLocalUsageSnapshot(snapshotPath) {
42
+ try {
43
+ const parsed = JSON.parse(await fs.readFile(snapshotPath, 'utf8'));
44
+ if (!isFiniteNumber(parsed.computedAtMs) || !isCodexLocalUsageStats(parsed.stats)) {
45
+ return null;
46
+ }
47
+ return { computedAtMs: parsed.computedAtMs, stats: parsed.stats };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ export async function writeCodexLocalUsageSnapshot(snapshotPath, snapshot) {
54
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
55
+ const temporaryPath = `${snapshotPath}.${process.pid}.tmp`;
56
+ await fs.writeFile(temporaryPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
57
+ await fs.rename(temporaryPath, snapshotPath);
58
+ }
40
59
  function resolveCodexHome() {
41
60
  return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
42
61
  }
@@ -69,8 +88,8 @@ async function readSessionUsage(filePath, turnIds) {
69
88
  const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
70
89
  let totalUsage = null;
71
90
  let usageEvents = 0;
72
- let generationStartMs = null;
73
- const outputSpeed = emptyOutputSpeedStats();
91
+ let activeTurn = null;
92
+ const completedTurnSamples = [];
74
93
  try {
75
94
  for await (const line of reader) {
76
95
  if (!line.trim())
@@ -87,28 +106,49 @@ async function readSessionUsage(filePath, turnIds) {
87
106
  if (turnId) {
88
107
  turnIds.add(turnId);
89
108
  }
90
- if (timestampMs !== null && isGenerationBoundary(event)) {
91
- generationStartMs = timestampMs;
109
+ if (timestampMs !== null
110
+ && turnId
111
+ && event?.type === 'event_msg'
112
+ && event?.payload?.type === 'task_started') {
113
+ activeTurn = { turnId, startedAtMs: timestampMs, visibleOutputTokens: 0 };
92
114
  }
93
115
  const info = event?.payload?.info;
94
116
  const lastTokenUsage = info?.last_token_usage ?? info?.lastTokenUsage;
95
117
  if (lastTokenUsage) {
96
118
  usageEvents += 1;
97
- addOutputSpeedSample(outputSpeed, lastTokenUsage, generationStartMs, timestampMs);
98
- if (timestampMs !== null) {
99
- generationStartMs = timestampMs;
119
+ if (activeTurn) {
120
+ activeTurn.visibleOutputTokens += visibleOutputTokens(lastTokenUsage);
100
121
  }
101
122
  }
102
123
  const totalTokenUsage = info?.total_token_usage ?? info?.totalTokenUsage;
103
124
  if (totalTokenUsage) {
104
125
  totalUsage = totalTokenUsage;
105
126
  }
127
+ const completedTurn = activeTurn;
128
+ if (completedTurn !== null
129
+ && timestampMs !== null
130
+ && turnId
131
+ && completedTurn.turnId === turnId
132
+ && event?.type === 'event_msg'
133
+ && (event?.payload?.type === 'task_complete' || event?.payload?.type === 'turn_aborted')) {
134
+ if (event.payload.type === 'task_complete' && completedTurn.visibleOutputTokens > 0) {
135
+ const seconds = (timestampMs - completedTurn.startedAtMs) / 1000;
136
+ if (Number.isFinite(seconds) && seconds > 0) {
137
+ completedTurnSamples.push({
138
+ completedAtMs: timestampMs,
139
+ visibleOutputTokens: completedTurn.visibleOutputTokens,
140
+ seconds,
141
+ });
142
+ }
143
+ }
144
+ activeTurn = null;
145
+ }
106
146
  }
107
147
  }
108
148
  finally {
109
149
  reader.close();
110
150
  }
111
- return { usageEvents, totalUsage, outputSpeed };
151
+ return { usageEvents, totalUsage, completedTurnSamples };
112
152
  }
113
153
  function emptyTotals() {
114
154
  return {
@@ -119,14 +159,37 @@ function emptyTotals() {
119
159
  totalTokens: 0,
120
160
  };
121
161
  }
122
- function emptyOutputSpeedStats() {
123
- return {
124
- samples: 0,
125
- outputTokens: 0,
126
- seconds: 0,
127
- latestTokensPerSecond: null,
128
- latestSampleAtMs: null,
129
- };
162
+ function isCodexLocalUsageStats(value) {
163
+ if (!value || typeof value !== 'object') {
164
+ return false;
165
+ }
166
+ const stats = value;
167
+ const totals = stats.totals;
168
+ const throughput = stats.responseThroughput;
169
+ return isFiniteNumber(stats.sessionFiles)
170
+ && isFiniteNumber(stats.sessionsWithUsage)
171
+ && isFiniteNumber(stats.turns)
172
+ && isFiniteNumber(stats.usageEvents)
173
+ && Boolean(totals)
174
+ && isFiniteNumber(totals?.inputTokens)
175
+ && isFiniteNumber(totals?.cachedInputTokens)
176
+ && isFiniteNumber(totals?.outputTokens)
177
+ && isFiniteNumber(totals?.reasoningOutputTokens)
178
+ && isFiniteNumber(totals?.totalTokens)
179
+ && Boolean(throughput)
180
+ && isFiniteNumber(throughput?.completedTurns)
181
+ && isFiniteNumber(throughput?.visibleOutputTokens)
182
+ && isFiniteNumber(throughput?.seconds)
183
+ && isFiniteNumber(throughput?.recentCompletedTurns)
184
+ && isFiniteNumber(throughput?.recentVisibleOutputTokens)
185
+ && isFiniteNumber(throughput?.recentSeconds)
186
+ && isNullableFiniteNumber(stats.latestSessionMtimeMs);
187
+ }
188
+ function isFiniteNumber(value) {
189
+ return typeof value === 'number' && Number.isFinite(value);
190
+ }
191
+ function isNullableFiniteNumber(value) {
192
+ return value === null || isFiniteNumber(value);
130
193
  }
131
194
  function addUsage(totals, usage) {
132
195
  const inputTokens = numberField(usage, 'input_tokens', 'inputTokens');
@@ -138,34 +201,21 @@ function addUsage(totals, usage) {
138
201
  totals.reasoningOutputTokens += numberField(usage, 'reasoning_output_tokens', 'reasoningOutputTokens');
139
202
  totals.totalTokens += totalTokens || inputTokens + outputTokens;
140
203
  }
141
- function addOutputSpeed(target, source) {
142
- target.samples += source.samples;
143
- target.outputTokens += source.outputTokens;
144
- target.seconds += source.seconds;
145
- if (source.latestTokensPerSecond !== null
146
- && source.latestSampleAtMs !== null
147
- && (target.latestSampleAtMs === null || source.latestSampleAtMs > target.latestSampleAtMs)) {
148
- target.latestTokensPerSecond = source.latestTokensPerSecond;
149
- target.latestSampleAtMs = source.latestSampleAtMs;
150
- }
204
+ function visibleOutputTokens(usage) {
205
+ return Math.max(0, numberField(usage, 'output_tokens', 'outputTokens')
206
+ - numberField(usage, 'reasoning_output_tokens', 'reasoningOutputTokens'));
151
207
  }
152
- function addOutputSpeedSample(stats, usage, generationStartMs, timestampMs) {
153
- if (generationStartMs === null || timestampMs === null || timestampMs <= generationStartMs) {
154
- return;
155
- }
156
- const outputTokens = numberField(usage, 'output_tokens', 'outputTokens');
157
- if (outputTokens <= 0) {
158
- return;
159
- }
160
- const seconds = (timestampMs - generationStartMs) / 1000;
161
- if (!Number.isFinite(seconds) || seconds <= 0) {
162
- return;
163
- }
164
- stats.samples += 1;
165
- stats.outputTokens += outputTokens;
166
- stats.seconds += seconds;
167
- stats.latestTokensPerSecond = outputTokens / seconds;
168
- stats.latestSampleAtMs = timestampMs;
208
+ function summarizeResponseThroughput(samples) {
209
+ const ordered = samples.slice().sort((left, right) => left.completedAtMs - right.completedAtMs);
210
+ const recent = ordered.slice(-RECENT_COMPLETED_TURNS);
211
+ return {
212
+ completedTurns: ordered.length,
213
+ visibleOutputTokens: ordered.reduce((total, sample) => total + sample.visibleOutputTokens, 0),
214
+ seconds: ordered.reduce((total, sample) => total + sample.seconds, 0),
215
+ recentCompletedTurns: recent.length,
216
+ recentVisibleOutputTokens: recent.reduce((total, sample) => total + sample.visibleOutputTokens, 0),
217
+ recentSeconds: recent.reduce((total, sample) => total + sample.seconds, 0),
218
+ };
169
219
  }
170
220
  function numberField(source, snakeKey, camelKey) {
171
221
  const snakeValue = source[snakeKey];
@@ -180,15 +230,3 @@ function parseTimestampMs(value) {
180
230
  const timestamp = Date.parse(value);
181
231
  return Number.isFinite(timestamp) ? timestamp : null;
182
232
  }
183
- function isGenerationBoundary(event) {
184
- const payload = event?.payload;
185
- if (event?.type === 'response_item' && payload?.type === 'function_call_output') {
186
- return true;
187
- }
188
- if (event?.type !== 'event_msg') {
189
- return false;
190
- }
191
- return payload?.type === 'task_started'
192
- || payload?.type === 'exec_command_end'
193
- || payload?.type === 'user_message';
194
- }
@@ -32,6 +32,10 @@ export declare class BridgeSessionCore {
32
32
  private authRotationInProgress;
33
33
  private authRotationFailedTargets;
34
34
  private localUsageCache;
35
+ private localUsageCacheLoaded;
36
+ private localUsageRefresh;
37
+ private authQuotaSnapshots;
38
+ private authQuotaSnapshotsLoaded;
35
39
  private lastRemoteControlStatus;
36
40
  private pendingThreadRenames;
37
41
  private pendingThreadNewCwds;
@@ -263,9 +267,16 @@ export declare class BridgeSessionCore {
263
267
  private buildNativeCollaborationMode;
264
268
  private buildCodexUsageStatusLines;
265
269
  private buildCodexLocalUsageStatusLines;
266
- private formatCodexLocalOutputSpeedStatusLines;
270
+ private formatCodexLocalResponseThroughputStatusLines;
267
271
  private resolveFastStatusLabel;
268
272
  private readCachedCodexLocalUsageStats;
273
+ private refreshCodexLocalUsageIfNeeded;
274
+ private refreshCodexLocalUsageStats;
275
+ private codexLocalUsageSnapshotPath;
276
+ private refreshCurrentCodexAuthQuota;
277
+ private readCodexAuthQuotaSnapshots;
278
+ private writeCodexAuthQuotaSnapshots;
279
+ private codexAuthQuotaSnapshotPath;
269
280
  private sendThreadContextSummary;
270
281
  private handleModelCommand;
271
282
  private handleEffortCommand;
@@ -11,7 +11,7 @@ import { TELEGRAM_MESSAGE_LIMIT, chunkTelegramMessage, chunkTelegramStreamMessag
11
11
  import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
12
12
  import { BRIDGE_SCOPE_WEIXIN_PREFIX, parseTelegramTargetFromBridgeScope, parseWeixinBridgeScope } from '../core/bridge_scope.js';
13
13
  import { resolveTelegramRenderRoute } from '../telegram/rendering.js';
14
- import { readCodexLocalUsageStats } from '../codex_app/local_usage.js';
14
+ import { readCodexLocalUsageSnapshot, readCodexLocalUsageStats, writeCodexLocalUsageSnapshot, } from '../codex_app/local_usage.js';
15
15
  import { normalizeTurnActivityEvent, } from './activity.js';
16
16
  import { normalizeAccessPreset, resolveAccessMode } from './access.js';
17
17
  import { diffObservedTurn, findLatestTurn, findLiveTurn } from './observer.js';
@@ -23,7 +23,9 @@ class UserFacingError extends Error {
23
23
  const OBSERVED_THREAD_POLL_MS = 1500;
24
24
  const OBSERVED_CLI_USER_LABEL = 'codex-cli-user';
25
25
  const DEFAULT_COLLABORATION_MODE = 'default';
26
- const CODEX_LOCAL_USAGE_CACHE_MS = 30_000;
26
+ const CODEX_LOCAL_USAGE_REFRESH_MS = 30 * 60_000;
27
+ const CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME = 'codex-local-usage.json';
28
+ const CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME = 'codex-auth-quota.json';
27
29
  const USER_INPUT_SUBMITTED_NOTICE_MS = 90_000;
28
30
  const SELF_UPDATE_STATUS_POLL_MS = 1000;
29
31
  const PLAN_IMPLEMENTATION_CODING_MESSAGE = 'Implement the plan.';
@@ -110,6 +112,10 @@ export class BridgeSessionCore {
110
112
  authRotationInProgress = false;
111
113
  authRotationFailedTargets = new Set();
112
114
  localUsageCache = null;
115
+ localUsageCacheLoaded = false;
116
+ localUsageRefresh = null;
117
+ authQuotaSnapshots = {};
118
+ authQuotaSnapshotsLoaded = false;
113
119
  lastRemoteControlStatus = null;
114
120
  pendingThreadRenames = new Map();
115
121
  pendingThreadNewCwds = new Map();
@@ -186,6 +192,9 @@ export class BridgeSessionCore {
186
192
  await this.app.start();
187
193
  await this.restorePendingUserInputs();
188
194
  await this.cleanupStaleTurnPreviews();
195
+ void this.refreshCodexLocalUsageIfNeeded().catch((error) => {
196
+ this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
197
+ });
189
198
  this.updateStatus();
190
199
  }
191
200
  /** Begin Telegram Bot API long-polling after handlers and Codex are ready. */
@@ -348,7 +357,11 @@ export class BridgeSessionCore {
348
357
  const binding = this.store.getBinding(scopeId);
349
358
  const settings = this.store.getChatSettings(scopeId);
350
359
  const access = this.resolveEffectiveAccess(scopeId, settings);
351
- const fastStatus = await this.resolveFastStatusLabel(locale, settings);
360
+ const [fastStatus, codexUsageLines, codexLocalUsageLines] = await Promise.all([
361
+ this.resolveFastStatusLabel(locale, settings),
362
+ this.buildCodexUsageStatusLines(locale),
363
+ this.buildCodexLocalUsageStatusLines(locale),
364
+ ]);
352
365
  const appServer = this.app.getServerStatus();
353
366
  const appServerLabel = appServer.pid && appServer.port
354
367
  ? `${appServer.running ? t(locale, 'status_app_server_running') : t(locale, 'status_app_server_stale')} pid=${appServer.pid} port=${appServer.port}`
@@ -376,8 +389,8 @@ export class BridgeSessionCore {
376
389
  t(locale, 'status_pending_user_inputs', { value: this.store.countPendingUserInputs() }),
377
390
  t(locale, 'status_active_turns', { value: this.activeTurns.size }),
378
391
  ];
379
- lines.push(...await this.buildCodexUsageStatusLines(locale));
380
- lines.push(...await this.buildCodexLocalUsageStatusLines(locale));
392
+ lines.push(...codexUsageLines);
393
+ lines.push(...codexLocalUsageLines);
381
394
  await this.sendMessage(scopeId, lines.join('\n'));
382
395
  return;
383
396
  }
@@ -3743,6 +3756,7 @@ export class BridgeSessionCore {
3743
3756
  return;
3744
3757
  }
3745
3758
  const state = await this.listCodexAuthState();
3759
+ await this.refreshCurrentCodexAuthQuota(state);
3746
3760
  const record = {
3747
3761
  localId: crypto.randomBytes(8).toString('hex'),
3748
3762
  chatId: scopeId,
@@ -4384,7 +4398,12 @@ export class BridgeSessionCore {
4384
4398
  return candidate ? { candidate, fromLabel: state.currentLabel, toLabel: await authPathDisplayLabel(candidate.path) } : null;
4385
4399
  }
4386
4400
  async listCodexAuthState() {
4387
- return listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames());
4401
+ const state = await listCodexAuthState(this.store.listDisabledCodexAuthCandidateNames());
4402
+ const snapshots = await this.readCodexAuthQuotaSnapshots();
4403
+ state.candidates.forEach((candidate) => {
4404
+ candidate.quota = snapshots[candidate.name] ?? null;
4405
+ });
4406
+ return state;
4388
4407
  }
4389
4408
  async readCodexAuthSwitchLabels(candidate) {
4390
4409
  const state = await this.listCodexAuthState();
@@ -4486,7 +4505,7 @@ export class BridgeSessionCore {
4486
4505
  }
4487
4506
  lines.push(t(locale, 'status_codex_usage_window', {
4488
4507
  window: formatRateLimitWindowLabel(locale, window, kind),
4489
- percent: formatUsagePercent(window.usedPercent),
4508
+ percent: formatRemainingUsagePercent(window.usedPercent),
4490
4509
  reset: window.resetsAt
4491
4510
  ? t(locale, 'status_codex_usage_reset', { value: formatLocalTimestamp(window.resetsAt) })
4492
4511
  : '',
@@ -4505,7 +4524,14 @@ export class BridgeSessionCore {
4505
4524
  }
4506
4525
  async buildCodexLocalUsageStatusLines(locale) {
4507
4526
  try {
4508
- const stats = await this.readCachedCodexLocalUsageStats();
4527
+ const snapshot = await this.readCachedCodexLocalUsageStats();
4528
+ void this.refreshCodexLocalUsageIfNeeded(snapshot).catch((error) => {
4529
+ this.logger.warn('codex.local_usage_background_refresh_failed', { error: formatUserError(error) });
4530
+ });
4531
+ if (!snapshot) {
4532
+ return [t(locale, 'status_codex_local_usage_refreshing')];
4533
+ }
4534
+ const stats = snapshot.stats;
4509
4535
  if (stats.sessionFiles === 0 || stats.sessionsWithUsage === 0) {
4510
4536
  return [];
4511
4537
  }
@@ -4518,11 +4544,15 @@ export class BridgeSessionCore {
4518
4544
  t(locale, 'status_codex_local_tokens', {
4519
4545
  total: formatTokenCount(stats.totals.totalTokens),
4520
4546
  input: formatTokenCount(stats.totals.inputTokens),
4547
+ visible: formatTokenCount(Math.max(0, stats.totals.outputTokens - stats.totals.reasoningOutputTokens)),
4521
4548
  output: formatTokenCount(stats.totals.outputTokens),
4522
4549
  cached: formatTokenCount(stats.totals.cachedInputTokens),
4523
4550
  reasoning: formatTokenCount(stats.totals.reasoningOutputTokens),
4524
4551
  }),
4525
- ...this.formatCodexLocalOutputSpeedStatusLines(locale, stats),
4552
+ ...this.formatCodexLocalResponseThroughputStatusLines(locale, stats),
4553
+ t(locale, 'status_codex_local_snapshot_at', {
4554
+ value: formatLocalTimestamp(snapshot.computedAtMs / 1000),
4555
+ }),
4526
4556
  ];
4527
4557
  }
4528
4558
  catch (error) {
@@ -4530,16 +4560,21 @@ export class BridgeSessionCore {
4530
4560
  return [t(locale, 'status_codex_local_usage_unavailable', { error: formatShortStatusError(error) })];
4531
4561
  }
4532
4562
  }
4533
- formatCodexLocalOutputSpeedStatusLines(locale, stats) {
4534
- const speed = stats.outputSpeed;
4535
- if (speed.samples === 0 || speed.outputTokens <= 0 || speed.seconds <= 0) {
4563
+ formatCodexLocalResponseThroughputStatusLines(locale, stats) {
4564
+ const throughput = stats.responseThroughput;
4565
+ if (throughput.completedTurns === 0
4566
+ || throughput.visibleOutputTokens <= 0
4567
+ || throughput.seconds <= 0
4568
+ || throughput.recentCompletedTurns === 0
4569
+ || throughput.recentVisibleOutputTokens <= 0
4570
+ || throughput.recentSeconds <= 0) {
4536
4571
  return [];
4537
4572
  }
4538
- const avg = speed.outputTokens / speed.seconds;
4539
- return [t(locale, 'status_codex_local_speed', {
4540
- avg: formatCompactNumber(avg),
4541
- latest: speed.latestTokensPerSecond === null ? t(locale, 'unknown') : formatCompactNumber(speed.latestTokensPerSecond),
4542
- samples: formatTokenCount(speed.samples),
4573
+ return [t(locale, 'status_codex_local_throughput', {
4574
+ overall: formatCompactNumber(throughput.visibleOutputTokens / throughput.seconds),
4575
+ recent: formatCompactNumber(throughput.recentVisibleOutputTokens / throughput.recentSeconds),
4576
+ recentTurns: formatTokenCount(throughput.recentCompletedTurns),
4577
+ turns: formatTokenCount(throughput.completedTurns),
4543
4578
  })];
4544
4579
  }
4545
4580
  async resolveFastStatusLabel(locale, settings) {
@@ -4554,13 +4589,87 @@ export class BridgeSessionCore {
4554
4589
  }
4555
4590
  }
4556
4591
  async readCachedCodexLocalUsageStats() {
4557
- const now = Date.now();
4558
- if (this.localUsageCache && this.localUsageCache.expiresAt > now) {
4559
- return this.localUsageCache.stats;
4592
+ if (this.localUsageCacheLoaded) {
4593
+ return this.localUsageCache;
4560
4594
  }
4595
+ this.localUsageCache = await readCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath());
4596
+ this.localUsageCacheLoaded = true;
4597
+ return this.localUsageCache;
4598
+ }
4599
+ async refreshCodexLocalUsageIfNeeded(snapshot) {
4600
+ const current = snapshot === undefined ? await this.readCachedCodexLocalUsageStats() : snapshot;
4601
+ if (current && Date.now() - current.computedAtMs < CODEX_LOCAL_USAGE_REFRESH_MS) {
4602
+ return;
4603
+ }
4604
+ if (this.localUsageRefresh) {
4605
+ return;
4606
+ }
4607
+ this.localUsageRefresh = this.refreshCodexLocalUsageStats().finally(() => {
4608
+ this.localUsageRefresh = null;
4609
+ });
4610
+ await this.localUsageRefresh;
4611
+ }
4612
+ async refreshCodexLocalUsageStats() {
4561
4613
  const stats = await readCodexLocalUsageStats();
4562
- this.localUsageCache = { stats, expiresAt: now + CODEX_LOCAL_USAGE_CACHE_MS };
4563
- return stats;
4614
+ const snapshot = { computedAtMs: Date.now(), stats };
4615
+ this.localUsageCache = snapshot;
4616
+ this.localUsageCacheLoaded = true;
4617
+ await writeCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath(), snapshot);
4618
+ }
4619
+ codexLocalUsageSnapshotPath() {
4620
+ return path.join(path.dirname(this.config.statusPath), CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME);
4621
+ }
4622
+ async refreshCurrentCodexAuthQuota(state) {
4623
+ const candidate = state.candidates.find(entry => entry.isCurrent);
4624
+ if (!candidate) {
4625
+ return;
4626
+ }
4627
+ try {
4628
+ const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
4629
+ if (!snapshot) {
4630
+ return;
4631
+ }
4632
+ const quota = authQuotaSnapshotFromRateLimit(snapshot);
4633
+ candidate.quota = quota;
4634
+ this.authQuotaSnapshots[candidate.name] = quota;
4635
+ await this.writeCodexAuthQuotaSnapshots();
4636
+ }
4637
+ catch (error) {
4638
+ this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
4639
+ }
4640
+ }
4641
+ async readCodexAuthQuotaSnapshots() {
4642
+ if (this.authQuotaSnapshotsLoaded) {
4643
+ return this.authQuotaSnapshots;
4644
+ }
4645
+ this.authQuotaSnapshotsLoaded = true;
4646
+ try {
4647
+ const parsed = JSON.parse(await fs.readFile(this.codexAuthQuotaSnapshotPath(), 'utf8'));
4648
+ if (parsed && typeof parsed === 'object') {
4649
+ for (const [name, value] of Object.entries(parsed)) {
4650
+ if (isCodexAuthQuotaSnapshot(value)) {
4651
+ this.authQuotaSnapshots[name] = value;
4652
+ }
4653
+ }
4654
+ }
4655
+ }
4656
+ catch {
4657
+ // A missing or invalid historical cache should not block the auth panel.
4658
+ }
4659
+ return this.authQuotaSnapshots;
4660
+ }
4661
+ async writeCodexAuthQuotaSnapshots() {
4662
+ const snapshotPath = this.codexAuthQuotaSnapshotPath();
4663
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
4664
+ const temporaryPath = `${snapshotPath}.${process.pid}.tmp`;
4665
+ await fs.writeFile(temporaryPath, `${JSON.stringify(this.authQuotaSnapshots, null, 2)}\n`, {
4666
+ encoding: 'utf8',
4667
+ mode: 0o600,
4668
+ });
4669
+ await fs.rename(temporaryPath, snapshotPath);
4670
+ }
4671
+ codexAuthQuotaSnapshotPath() {
4672
+ return path.join(path.dirname(this.config.statusPath), CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME);
4564
4673
  }
4565
4674
  async sendThreadContextSummary(scopeId, locale, threadId) {
4566
4675
  try {
@@ -7039,6 +7148,7 @@ async function listCodexAuthState(disabledNames = new Set()) {
7039
7148
  isCurrent: currentTargetPath === candidatePath,
7040
7149
  disabled: disabledNames.has(entry.name),
7041
7150
  mtimeMs: stat.mtimeMs,
7151
+ quota: null,
7042
7152
  });
7043
7153
  }
7044
7154
  candidates.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true }));
@@ -7138,12 +7248,13 @@ function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7138
7248
  return lines.join('\n');
7139
7249
  }
7140
7250
  lines.push(t(locale, 'auth_candidate_count', { value: state.candidates.length }));
7251
+ lines.push(t(locale, 'auth_quota_legend'));
7141
7252
  state.candidates.forEach((candidate, index) => {
7142
7253
  const marker = candidate.isCurrent ? ' *' : '';
7143
7254
  const status = candidate.disabled
7144
7255
  ? t(locale, 'auth_candidate_status_disabled')
7145
7256
  : t(locale, 'auth_candidate_status_enabled');
7146
- lines.push(`${index + 1}. ${candidate.name}${marker} [${status}]`);
7257
+ lines.push(`${index + 1}. ${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${marker} [${status}]`);
7147
7258
  });
7148
7259
  if (includeWeixinCopyPaste) {
7149
7260
  lines.push('', t(locale, 'weixin_copy_paste_divider'), t(locale, 'weixin_copy_auth_title'), ...state.candidates.map((_candidate, index) => `/auth use ${index + 1}`), ...state.candidates.map((candidate, index) => candidate.disabled
@@ -7155,7 +7266,7 @@ function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7155
7266
  function authChoiceKeyboard(locale, record) {
7156
7267
  const rows = record.candidates.map((candidate, index) => [
7157
7268
  {
7158
- text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${candidate.name}${candidate.disabled ? ' · off' : ''}`),
7269
+ text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${candidate.disabled ? ' · off' : ''}`),
7159
7270
  callback_data: `auth:${record.localId}:${index}`,
7160
7271
  },
7161
7272
  {
@@ -7692,6 +7803,44 @@ function formatUsagePercent(value) {
7692
7803
  }
7693
7804
  return formatCompactNumber(value);
7694
7805
  }
7806
+ function formatRemainingUsagePercent(usedPercent) {
7807
+ const remainingPercent = remainingUsagePercent(usedPercent);
7808
+ return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
7809
+ }
7810
+ function authQuotaSnapshotFromRateLimit(snapshot) {
7811
+ return {
7812
+ capturedAtMs: Date.now(),
7813
+ primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
7814
+ secondaryRemainingPercent: snapshot.secondary ? remainingUsagePercent(snapshot.secondary.usedPercent) : null,
7815
+ };
7816
+ }
7817
+ function remainingUsagePercent(usedPercent) {
7818
+ if (!Number.isFinite(usedPercent)) {
7819
+ return null;
7820
+ }
7821
+ return Math.max(0, Math.min(100, 100 - usedPercent));
7822
+ }
7823
+ function formatAuthQuotaPrefix(snapshot) {
7824
+ if (!snapshot) {
7825
+ return '--|--';
7826
+ }
7827
+ const primary = snapshot.primaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.primaryRemainingPercent);
7828
+ const secondary = snapshot.secondaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.secondaryRemainingPercent);
7829
+ return `${primary}|${secondary}`;
7830
+ }
7831
+ function isCodexAuthQuotaSnapshot(value) {
7832
+ if (!value || typeof value !== 'object') {
7833
+ return false;
7834
+ }
7835
+ const snapshot = value;
7836
+ return typeof snapshot.capturedAtMs === 'number'
7837
+ && Number.isFinite(snapshot.capturedAtMs)
7838
+ && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
7839
+ && isNullableFiniteNumber(snapshot.secondaryRemainingPercent);
7840
+ }
7841
+ function isNullableFiniteNumber(value) {
7842
+ return value === null || (typeof value === 'number' && Number.isFinite(value));
7843
+ }
7695
7844
  function formatCompactNumber(value) {
7696
7845
  return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '');
7697
7846
  }
package/dist/i18n.d.ts CHANGED
@@ -90,7 +90,7 @@ declare const MESSAGES: {
90
90
  readonly status_app_server: "Codex app-server: {value}";
91
91
  readonly status_app_server_running: "running";
92
92
  readonly status_app_server_stale: "stale";
93
- readonly status_user_agent: "User agent: {value}";
93
+ readonly status_user_agent: "Codex/FoxClaw agent: {value}";
94
94
  readonly status_current_thread: "Current thread: {value}";
95
95
  readonly status_configured_model: "Configured model: {value}";
96
96
  readonly status_configured_effort: "Configured effort: {value}";
@@ -107,12 +107,14 @@ declare const MESSAGES: {
107
107
  readonly status_codex_account: "Codex account: {value}";
108
108
  readonly status_codex_plan: "Codex plan: {value}";
109
109
  readonly status_codex_usage_title: "Codex usage ({value}):";
110
- readonly status_codex_usage_window: "{window}: {percent}% used{reset}";
110
+ readonly status_codex_usage_window: "{window}: {percent}% remaining{reset}";
111
111
  readonly status_codex_usage_reset: ", resets {value}";
112
112
  readonly status_codex_usage_unavailable: "Codex usage: unavailable ({error})";
113
113
  readonly status_codex_local_history: "Codex local history: {sessions} sessions, {turns} turns, {events} usage records";
114
- readonly status_codex_local_tokens: "Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}";
115
- readonly status_codex_local_speed: "Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)";
114
+ readonly status_codex_local_tokens: "Codex local tokens: total {total}; input {input}, visible output {visible}, reasoning output {reasoning}, total output {output}, cached input {cached}";
115
+ readonly status_codex_local_throughput: "Codex visible reply throughput (end-to-end, excluding reasoning): overall {overall} token/s, last {recentTurns} completed turns {recent} token/s ({turns} completed turns sampled)";
116
+ readonly status_codex_local_snapshot_at: "Codex local stats snapshot: {value}";
117
+ readonly status_codex_local_usage_refreshing: "Codex local history: building snapshot in background";
116
118
  readonly status_codex_local_usage_unavailable: "Codex local history: unavailable ({error})";
117
119
  readonly status_codex_credits: "Codex credits: {value}";
118
120
  readonly status_codex_limit_reached: "Codex limit: {value}";
@@ -131,6 +133,7 @@ declare const MESSAGES: {
131
133
  readonly auth_current: "Current auth: {value}";
132
134
  readonly auth_dir: "Auth dir: {value}";
133
135
  readonly auth_candidate_count: "Candidates: {value}";
136
+ readonly auth_quota_legend: "Quota remaining: 5h|7d|auth";
134
137
  readonly auth_candidate_status_enabled: "enabled";
135
138
  readonly auth_candidate_status_disabled: "disabled";
136
139
  readonly auth_candidate_enabled: "Enabled auth candidate for auto-rotation: {value}";
@@ -651,7 +654,7 @@ declare const MESSAGES: {
651
654
  readonly status_app_server: "Codex app-server:{value}";
652
655
  readonly status_app_server_running: "运行中";
653
656
  readonly status_app_server_stale: "失效";
654
- readonly status_user_agent: "客户端:{value}";
657
+ readonly status_user_agent: "Codex/FoxClaw 标识:{value}";
655
658
  readonly status_current_thread: "当前线程:{value}";
656
659
  readonly status_configured_model: "已配置模型:{value}";
657
660
  readonly status_configured_effort: "已配置推理强度:{value}";
@@ -668,12 +671,14 @@ declare const MESSAGES: {
668
671
  readonly status_codex_account: "Codex 账号:{value}";
669
672
  readonly status_codex_plan: "Codex 套餐:{value}";
670
673
  readonly status_codex_usage_title: "Codex 用量({value}):";
671
- readonly status_codex_usage_window: "{window}:已用 {percent}%{reset}";
674
+ readonly status_codex_usage_window: "{window}:剩余 {percent}%{reset}";
672
675
  readonly status_codex_usage_reset: ",重置时间 {value}";
673
676
  readonly status_codex_usage_unavailable: "Codex 用量:无法获取({error})";
674
677
  readonly status_codex_local_history: "Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录";
675
- readonly status_codex_local_tokens: "Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}";
676
- readonly status_codex_local_speed: "Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)";
678
+ readonly status_codex_local_tokens: "Codex 本地 Token:总计 {total};输入 {input},可见输出 {visible},推理输出 {reasoning},总输出 {output},缓存输入 {cached}";
679
+ readonly status_codex_local_throughput: "Codex 可见答复吞吐(端到端,排除推理 token):整体 {overall} token/s,最近 {recentTurns} 个完成轮次 {recent} token/s({turns} 个完成轮次样本)";
680
+ readonly status_codex_local_snapshot_at: "Codex 本地统计快照:{value}";
681
+ readonly status_codex_local_usage_refreshing: "Codex 本地历史:正在后台生成统计快照";
677
682
  readonly status_codex_local_usage_unavailable: "Codex 本地历史:无法获取({error})";
678
683
  readonly status_codex_credits: "Codex 额度:{value}";
679
684
  readonly status_codex_limit_reached: "Codex 限制:{value}";
@@ -692,6 +697,7 @@ declare const MESSAGES: {
692
697
  readonly auth_current: "当前 auth:{value}";
693
698
  readonly auth_dir: "Auth 目录:{value}";
694
699
  readonly auth_candidate_count: "候选数量:{value}";
700
+ readonly auth_quota_legend: "额度剩余:5h|7d|auth";
695
701
  readonly auth_candidate_status_enabled: "启用";
696
702
  readonly auth_candidate_status_disabled: "禁用";
697
703
  readonly auth_candidate_enabled: "已启用 auth 候选自动轮换:{value}";
package/dist/i18n.js CHANGED
@@ -88,7 +88,7 @@ const MESSAGES = {
88
88
  status_app_server: 'Codex app-server: {value}',
89
89
  status_app_server_running: 'running',
90
90
  status_app_server_stale: 'stale',
91
- status_user_agent: 'User agent: {value}',
91
+ status_user_agent: 'Codex/FoxClaw agent: {value}',
92
92
  status_current_thread: 'Current thread: {value}',
93
93
  status_configured_model: 'Configured model: {value}',
94
94
  status_configured_effort: 'Configured effort: {value}',
@@ -105,12 +105,14 @@ const MESSAGES = {
105
105
  status_codex_account: 'Codex account: {value}',
106
106
  status_codex_plan: 'Codex plan: {value}',
107
107
  status_codex_usage_title: 'Codex usage ({value}):',
108
- status_codex_usage_window: '{window}: {percent}% used{reset}',
108
+ status_codex_usage_window: '{window}: {percent}% remaining{reset}',
109
109
  status_codex_usage_reset: ', resets {value}',
110
110
  status_codex_usage_unavailable: 'Codex usage: unavailable ({error})',
111
111
  status_codex_local_history: 'Codex local history: {sessions} sessions, {turns} turns, {events} usage records',
112
- status_codex_local_tokens: 'Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}',
113
- status_codex_local_speed: 'Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)',
112
+ status_codex_local_tokens: 'Codex local tokens: total {total}; input {input}, visible output {visible}, reasoning output {reasoning}, total output {output}, cached input {cached}',
113
+ status_codex_local_throughput: 'Codex visible reply throughput (end-to-end, excluding reasoning): overall {overall} token/s, last {recentTurns} completed turns {recent} token/s ({turns} completed turns sampled)',
114
+ status_codex_local_snapshot_at: 'Codex local stats snapshot: {value}',
115
+ status_codex_local_usage_refreshing: 'Codex local history: building snapshot in background',
114
116
  status_codex_local_usage_unavailable: 'Codex local history: unavailable ({error})',
115
117
  status_codex_credits: 'Codex credits: {value}',
116
118
  status_codex_limit_reached: 'Codex limit: {value}',
@@ -129,6 +131,7 @@ const MESSAGES = {
129
131
  auth_current: 'Current auth: {value}',
130
132
  auth_dir: 'Auth dir: {value}',
131
133
  auth_candidate_count: 'Candidates: {value}',
134
+ auth_quota_legend: 'Quota remaining: 5h|7d|auth',
132
135
  auth_candidate_status_enabled: 'enabled',
133
136
  auth_candidate_status_disabled: 'disabled',
134
137
  auth_candidate_enabled: 'Enabled auth candidate for auto-rotation: {value}',
@@ -649,7 +652,7 @@ const MESSAGES = {
649
652
  status_app_server: 'Codex app-server:{value}',
650
653
  status_app_server_running: '运行中',
651
654
  status_app_server_stale: '失效',
652
- status_user_agent: '客户端:{value}',
655
+ status_user_agent: 'Codex/FoxClaw 标识:{value}',
653
656
  status_current_thread: '当前线程:{value}',
654
657
  status_configured_model: '已配置模型:{value}',
655
658
  status_configured_effort: '已配置推理强度:{value}',
@@ -666,12 +669,14 @@ const MESSAGES = {
666
669
  status_codex_account: 'Codex 账号:{value}',
667
670
  status_codex_plan: 'Codex 套餐:{value}',
668
671
  status_codex_usage_title: 'Codex 用量({value}):',
669
- status_codex_usage_window: '{window}:已用 {percent}%{reset}',
672
+ status_codex_usage_window: '{window}:剩余 {percent}%{reset}',
670
673
  status_codex_usage_reset: ',重置时间 {value}',
671
674
  status_codex_usage_unavailable: 'Codex 用量:无法获取({error})',
672
675
  status_codex_local_history: 'Codex 本地历史:{sessions} 个会话,{turns} 轮,{events} 条用量记录',
673
- status_codex_local_tokens: 'Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}',
674
- status_codex_local_speed: 'Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)',
676
+ status_codex_local_tokens: 'Codex 本地 Token:总计 {total};输入 {input},可见输出 {visible},推理输出 {reasoning},总输出 {output},缓存输入 {cached}',
677
+ status_codex_local_throughput: 'Codex 可见答复吞吐(端到端,排除推理 token):整体 {overall} token/s,最近 {recentTurns} 个完成轮次 {recent} token/s({turns} 个完成轮次样本)',
678
+ status_codex_local_snapshot_at: 'Codex 本地统计快照:{value}',
679
+ status_codex_local_usage_refreshing: 'Codex 本地历史:正在后台生成统计快照',
675
680
  status_codex_local_usage_unavailable: 'Codex 本地历史:无法获取({error})',
676
681
  status_codex_credits: 'Codex 额度:{value}',
677
682
  status_codex_limit_reached: 'Codex 限制:{value}',
@@ -690,6 +695,7 @@ const MESSAGES = {
690
695
  auth_current: '当前 auth:{value}',
691
696
  auth_dir: 'Auth 目录:{value}',
692
697
  auth_candidate_count: '候选数量:{value}',
698
+ auth_quota_legend: '额度剩余:5h|7d|auth',
693
699
  auth_candidate_status_enabled: '启用',
694
700
  auth_candidate_status_disabled: '禁用',
695
701
  auth_candidate_enabled: '已启用 auth 候选自动轮换:{value}',
@@ -230,7 +230,7 @@ Later commands are sorted by recent usage. Plain text, photos, and files continu
230
230
 
231
231
  ### 3.2 `/status`, `/account`, `/quota`, `/update`
232
232
 
233
- - `/status`: FoxClaw, app-server, current thread binding, model, access, and Codex usage summary.
233
+ - `/status`: FoxClaw, app-server, current thread binding, model, access, and Codex usage summary. Local session, token, and visible-reply-throughput metrics use a background-generated historical snapshot instead of scanning large logs during the request; throughput is computed end-to-end for completed turns, excluding reasoning tokens while including waiting and tool execution time.
234
234
  - `/account`: current Codex account.
235
235
  - `/quota`: Codex usage and quota window.
236
236
  - `/update`: upgrade FoxClaw, run checks, and restart the service; it refuses while a turn, approval, or question is active, then reports the result after restart.
@@ -387,7 +387,7 @@ If the login is cancelled or fails, FoxClaw tries to restore the previous auth t
387
387
 
388
388
  ### 6.3 The `/auth` Panel
389
389
 
390
- `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload.
390
+ `/auth` lists candidate accounts, the current account, and the auth directory. It also provides buttons for switching, disabling, login, and reload. The `5h|7d` numbers before each filename are the last recorded remaining percentages for the two quota windows; the current auth is refreshed when the panel opens, while other candidates are not switched merely to query quota.
391
391
 
392
392
  Approximation:
393
393
 
@@ -396,16 +396,17 @@ Codex auth
396
396
  Current: auth.json_personal
397
397
  Auth dir: /home/alice/.codex
398
398
  Candidates: 2
399
- 1. auth.json_personal * [enabled]
400
- 2. auth.json_team [enabled]
399
+ Quota remaining: 5h|7d|auth
400
+ 1. 20|25|auth.json_personal * [enabled]
401
+ 2. --|--|auth.json_team [enabled]
401
402
 
402
- [✅ auth.json_personal] [✅]
403
- [🔐 auth.json_team] [✅]
403
+ [✅ 20|25|auth.json_personal] [✅]
404
+ [🔐 --|--|auth.json_team] [✅]
404
405
  [🛡️ Access] [🔑 Login]
405
406
  [🔄 Reload auth]
406
407
  ```
407
408
 
408
- The right-side `✅` / `⏸️` button shows the current state. Tapping it toggles enabled/disabled, and the refreshed list shows the new state.
409
+ The right-side `✅` / `⏸️` button shows the current state. Tapping it toggles enabled/disabled, and the refreshed list shows the new state. `--|--` means no quota snapshot has been observed for that candidate yet.
409
410
 
410
411
  Equivalent commands:
411
412
 
@@ -230,7 +230,7 @@ TG_ALLOWED_TOPIC_ID=42
230
230
 
231
231
  ### 3.2 `/status`、`/account`、`/quota`、`/update`
232
232
 
233
- - `/status`:查看 FoxClaw、app-server、当前绑定线程、模型、权限和 Codex 用量摘要。
233
+ - `/status`:查看 FoxClaw、app-server、当前绑定线程、模型、权限和 Codex 用量摘要。本地 session/Token/可见答复吞吐使用后台生成的历史快照,避免状态查询现场扫描大量日志;答复吞吐按完成轮次端到端耗时计算,排除推理 token,但包含等待与工具执行时间。
234
234
  - `/account`:查看当前 Codex 登录账号。
235
235
  - `/quota`:查看 Codex 用量和额度窗口。
236
236
  - `/update`:升级 FoxClaw、自检并重启服务;当前有运行中回复、审批或待确认问题时会拒绝执行,重启后会回报结果。
@@ -387,7 +387,7 @@ cp -L ~/.codex/auth.json ~/.codex/auth.json_personal
387
387
 
388
388
  ### 6.3 `/auth` 面板
389
389
 
390
- `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。
390
+ `/auth` 会列出候选账号、当前账号和 auth 目录,并提供按钮切换、禁用、登录和重载。每个候选名前的 `5h|7d` 数字表示上次记录到的两个额度窗口剩余百分比;当前 auth 会在打开面板时刷新,其他候选不会为了查询额度被自动切换。
391
391
 
392
392
  示意:
393
393
 
@@ -396,16 +396,17 @@ Codex auth
396
396
  Current: auth.json_personal
397
397
  Auth dir: /home/alice/.codex
398
398
  Candidates: 2
399
- 1. auth.json_personal * [enabled]
400
- 2. auth.json_team [enabled]
399
+ 额度剩余:5h|7d|auth
400
+ 1. 20|25|auth.json_personal * [enabled]
401
+ 2. --|--|auth.json_team [enabled]
401
402
 
402
- [✅ auth.json_personal] [✅]
403
- [🔐 auth.json_team] [✅]
403
+ [✅ 20|25|auth.json_personal] [✅]
404
+ [🔐 --|--|auth.json_team] [✅]
404
405
  [🛡️ Access] [🔑 设备登录]
405
406
  [🔄 Reload auth]
406
407
  ```
407
408
 
408
- 右侧 `✅` / `⏸️` 表示当前状态。点一下会切换启用/禁用,列表刷新后图标会随状态变化。
409
+ 右侧 `✅` / `⏸️` 表示当前状态。点一下会切换启用/禁用,列表刷新后图标会随状态变化。`--|--` 表示该候选还没有额度历史快照。
409
410
 
410
411
  命令等价用法:
411
412
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.3.15",
3
+ "version": "0.3.18",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -190,10 +190,12 @@ Use this checklist when the user asks for standard closing actions, release wrap
190
190
  - If `doctor` fails only because `DEFAULT_CWD` is missing, report that separately; do not treat it as evidence that the service update failed.
191
191
  7. Publish to npm when requested:
192
192
  - Prefer GitHub Actions trusted publishing via `.github/workflows/publish.yml`: bump and commit the package version, push `main`, then push a matching `v<version>` tag. The tag version must match `package.json`.
193
+ - Treat `workflow_dispatch` only as a retry path from an existing matching release tag; do not manually run publishing from `main`.
193
194
  - Configure npmjs.com trusted publishing for `foxden-app/foxclaw` and workflow `publish.yml`; do not store npm tokens if OIDC trusted publishing is available.
194
195
  - Temporary fallback: store an npm automation/bypass-2FA token as the GitHub Actions secret `NPM_TOKEN`. Never print the token or commit it.
195
196
  - Check `npm whoami` and `npm view @foxden-app/foxclaw version`.
196
197
  - If the target version is already published, bump with `npm version patch --no-git-tag-version` before validation and commit.
198
+ - If a workflow fails before `npm publish`, inspect that failed GitHub Actions step before attributing it to npm trusted publishing.
197
199
  - Manual fallback only: run `BROWSER=true npm publish` in a TTY.
198
200
  - If npm returns `ENEEDAUTH`, report that npm publish is blocked by registry login and leave the package unreported as published.
199
201
  - Never print npm tokens or `.npmrc` auth values.
@@ -73,6 +73,8 @@ Use this path when the repo has `.github/workflows/publish.yml` and npmjs.com ha
73
73
  npm view <package-name> version
74
74
  ```
75
75
 
76
+ If the workflow provides `workflow_dispatch` as a recovery path, dispatch it only from an existing release tag whose version matches `package.json`. Do not dispatch publishing from a branch containing an unpublished version unless the workflow explicitly resolves and validates a release tag.
77
+
76
78
  Do not store or print npm tokens when trusted publishing is available. If trusted publishing is not configured on npmjs.com, the workflow can use a GitHub Actions secret named `NPM_TOKEN` as a temporary fallback. Store only automation/bypass-2FA tokens there, never paste tokens in chat or commit them.
77
79
 
78
80
  ### Manual Publish
@@ -124,4 +126,5 @@ Press ENTER to open in the browser...
124
126
  - If `xdg-open` fails because the environment has no browser, rerun with `BROWSER=true npm publish`.
125
127
  - If the auth link expires or the publish process exits, rerun `BROWSER=true npm publish` to generate a new link.
126
128
  - If the user provides a classic authenticator OTP instead of using the web link, publish can be retried with `npm publish --otp <code>`, but prefer web auth when the user asks for a clickable confirmation link.
129
+ - If a GitHub Actions release fails before the `npm publish` step, such as during checkout or action download authentication, do not diagnose it as a trusted publishing rejection. Inspect the failed step and GitHub Actions status first.
127
130
  - Never print npm tokens or `.npmrc` auth values.