@foxden-app/foxclaw 0.3.14 → 0.3.17

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.
@@ -14,6 +14,10 @@ export interface CodexLocalUsageStats {
14
14
  outputSpeed: CodexLocalOutputSpeedStats;
15
15
  latestSessionMtimeMs: number | null;
16
16
  }
17
+ export interface CodexLocalUsageSnapshot {
18
+ computedAtMs: number;
19
+ stats: CodexLocalUsageStats;
20
+ }
17
21
  export interface CodexLocalOutputSpeedStats {
18
22
  samples: number;
19
23
  outputTokens: number;
@@ -22,3 +26,5 @@ export interface CodexLocalOutputSpeedStats {
22
26
  latestSampleAtMs: number | null;
23
27
  }
24
28
  export declare function readCodexLocalUsageStats(codexHome?: string): Promise<CodexLocalUsageStats>;
29
+ export declare function readCodexLocalUsageSnapshot(snapshotPath: string): Promise<CodexLocalUsageSnapshot | null>;
30
+ export declare function writeCodexLocalUsageSnapshot(snapshotPath: string, snapshot: CodexLocalUsageSnapshot): Promise<void>;
@@ -37,6 +37,24 @@ export async function readCodexLocalUsageStats(codexHome = resolveCodexHome()) {
37
37
  latestSessionMtimeMs,
38
38
  };
39
39
  }
40
+ export async function readCodexLocalUsageSnapshot(snapshotPath) {
41
+ try {
42
+ const parsed = JSON.parse(await fs.readFile(snapshotPath, 'utf8'));
43
+ if (!isFiniteNumber(parsed.computedAtMs) || !isCodexLocalUsageStats(parsed.stats)) {
44
+ return null;
45
+ }
46
+ return { computedAtMs: parsed.computedAtMs, stats: parsed.stats };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export async function writeCodexLocalUsageSnapshot(snapshotPath, snapshot) {
53
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
54
+ const temporaryPath = `${snapshotPath}.${process.pid}.tmp`;
55
+ await fs.writeFile(temporaryPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
56
+ await fs.rename(temporaryPath, snapshotPath);
57
+ }
40
58
  function resolveCodexHome() {
41
59
  return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
42
60
  }
@@ -128,6 +146,37 @@ function emptyOutputSpeedStats() {
128
146
  latestSampleAtMs: null,
129
147
  };
130
148
  }
149
+ function isCodexLocalUsageStats(value) {
150
+ if (!value || typeof value !== 'object') {
151
+ return false;
152
+ }
153
+ const stats = value;
154
+ const totals = stats.totals;
155
+ const speed = stats.outputSpeed;
156
+ return isFiniteNumber(stats.sessionFiles)
157
+ && isFiniteNumber(stats.sessionsWithUsage)
158
+ && isFiniteNumber(stats.turns)
159
+ && isFiniteNumber(stats.usageEvents)
160
+ && Boolean(totals)
161
+ && isFiniteNumber(totals?.inputTokens)
162
+ && isFiniteNumber(totals?.cachedInputTokens)
163
+ && isFiniteNumber(totals?.outputTokens)
164
+ && isFiniteNumber(totals?.reasoningOutputTokens)
165
+ && isFiniteNumber(totals?.totalTokens)
166
+ && Boolean(speed)
167
+ && isFiniteNumber(speed?.samples)
168
+ && isFiniteNumber(speed?.outputTokens)
169
+ && isFiniteNumber(speed?.seconds)
170
+ && isNullableFiniteNumber(speed?.latestTokensPerSecond)
171
+ && isNullableFiniteNumber(speed?.latestSampleAtMs)
172
+ && isNullableFiniteNumber(stats.latestSessionMtimeMs);
173
+ }
174
+ function isFiniteNumber(value) {
175
+ return typeof value === 'number' && Number.isFinite(value);
176
+ }
177
+ function isNullableFiniteNumber(value) {
178
+ return value === null || isFiniteNumber(value);
179
+ }
131
180
  function addUsage(totals, usage) {
132
181
  const inputTokens = numberField(usage, 'input_tokens', 'inputTokens');
133
182
  const outputTokens = numberField(usage, 'output_tokens', 'outputTokens');
@@ -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;
@@ -266,6 +270,13 @@ export declare class BridgeSessionCore {
266
270
  private formatCodexLocalOutputSpeedStatusLines;
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
  }
@@ -4523,6 +4549,9 @@ export class BridgeSessionCore {
4523
4549
  reasoning: formatTokenCount(stats.totals.reasoningOutputTokens),
4524
4550
  }),
4525
4551
  ...this.formatCodexLocalOutputSpeedStatusLines(locale, stats),
4552
+ t(locale, 'status_codex_local_snapshot_at', {
4553
+ value: formatLocalTimestamp(snapshot.computedAtMs / 1000),
4554
+ }),
4526
4555
  ];
4527
4556
  }
4528
4557
  catch (error) {
@@ -4554,13 +4583,87 @@ export class BridgeSessionCore {
4554
4583
  }
4555
4584
  }
4556
4585
  async readCachedCodexLocalUsageStats() {
4557
- const now = Date.now();
4558
- if (this.localUsageCache && this.localUsageCache.expiresAt > now) {
4559
- return this.localUsageCache.stats;
4586
+ if (this.localUsageCacheLoaded) {
4587
+ return this.localUsageCache;
4560
4588
  }
4589
+ this.localUsageCache = await readCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath());
4590
+ this.localUsageCacheLoaded = true;
4591
+ return this.localUsageCache;
4592
+ }
4593
+ async refreshCodexLocalUsageIfNeeded(snapshot) {
4594
+ const current = snapshot === undefined ? await this.readCachedCodexLocalUsageStats() : snapshot;
4595
+ if (current && Date.now() - current.computedAtMs < CODEX_LOCAL_USAGE_REFRESH_MS) {
4596
+ return;
4597
+ }
4598
+ if (this.localUsageRefresh) {
4599
+ return;
4600
+ }
4601
+ this.localUsageRefresh = this.refreshCodexLocalUsageStats().finally(() => {
4602
+ this.localUsageRefresh = null;
4603
+ });
4604
+ await this.localUsageRefresh;
4605
+ }
4606
+ async refreshCodexLocalUsageStats() {
4561
4607
  const stats = await readCodexLocalUsageStats();
4562
- this.localUsageCache = { stats, expiresAt: now + CODEX_LOCAL_USAGE_CACHE_MS };
4563
- return stats;
4608
+ const snapshot = { computedAtMs: Date.now(), stats };
4609
+ this.localUsageCache = snapshot;
4610
+ this.localUsageCacheLoaded = true;
4611
+ await writeCodexLocalUsageSnapshot(this.codexLocalUsageSnapshotPath(), snapshot);
4612
+ }
4613
+ codexLocalUsageSnapshotPath() {
4614
+ return path.join(path.dirname(this.config.statusPath), CODEX_LOCAL_USAGE_SNAPSHOT_FILENAME);
4615
+ }
4616
+ async refreshCurrentCodexAuthQuota(state) {
4617
+ const candidate = state.candidates.find(entry => entry.isCurrent);
4618
+ if (!candidate) {
4619
+ return;
4620
+ }
4621
+ try {
4622
+ const snapshot = selectCodexRateLimitSnapshot(await this.app.readAccountRateLimits());
4623
+ if (!snapshot) {
4624
+ return;
4625
+ }
4626
+ const quota = authQuotaSnapshotFromRateLimit(snapshot);
4627
+ candidate.quota = quota;
4628
+ this.authQuotaSnapshots[candidate.name] = quota;
4629
+ await this.writeCodexAuthQuotaSnapshots();
4630
+ }
4631
+ catch (error) {
4632
+ this.logger.warn('codex.auth_quota_refresh_failed', { error: formatUserError(error) });
4633
+ }
4634
+ }
4635
+ async readCodexAuthQuotaSnapshots() {
4636
+ if (this.authQuotaSnapshotsLoaded) {
4637
+ return this.authQuotaSnapshots;
4638
+ }
4639
+ this.authQuotaSnapshotsLoaded = true;
4640
+ try {
4641
+ const parsed = JSON.parse(await fs.readFile(this.codexAuthQuotaSnapshotPath(), 'utf8'));
4642
+ if (parsed && typeof parsed === 'object') {
4643
+ for (const [name, value] of Object.entries(parsed)) {
4644
+ if (isCodexAuthQuotaSnapshot(value)) {
4645
+ this.authQuotaSnapshots[name] = value;
4646
+ }
4647
+ }
4648
+ }
4649
+ }
4650
+ catch {
4651
+ // A missing or invalid historical cache should not block the auth panel.
4652
+ }
4653
+ return this.authQuotaSnapshots;
4654
+ }
4655
+ async writeCodexAuthQuotaSnapshots() {
4656
+ const snapshotPath = this.codexAuthQuotaSnapshotPath();
4657
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
4658
+ const temporaryPath = `${snapshotPath}.${process.pid}.tmp`;
4659
+ await fs.writeFile(temporaryPath, `${JSON.stringify(this.authQuotaSnapshots, null, 2)}\n`, {
4660
+ encoding: 'utf8',
4661
+ mode: 0o600,
4662
+ });
4663
+ await fs.rename(temporaryPath, snapshotPath);
4664
+ }
4665
+ codexAuthQuotaSnapshotPath() {
4666
+ return path.join(path.dirname(this.config.statusPath), CODEX_AUTH_QUOTA_SNAPSHOT_FILENAME);
4564
4667
  }
4565
4668
  async sendThreadContextSummary(scopeId, locale, threadId) {
4566
4669
  try {
@@ -7039,6 +7142,7 @@ async function listCodexAuthState(disabledNames = new Set()) {
7039
7142
  isCurrent: currentTargetPath === candidatePath,
7040
7143
  disabled: disabledNames.has(entry.name),
7041
7144
  mtimeMs: stat.mtimeMs,
7145
+ quota: null,
7042
7146
  });
7043
7147
  }
7044
7148
  candidates.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true }));
@@ -7138,12 +7242,13 @@ function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7138
7242
  return lines.join('\n');
7139
7243
  }
7140
7244
  lines.push(t(locale, 'auth_candidate_count', { value: state.candidates.length }));
7245
+ lines.push(t(locale, 'auth_quota_legend'));
7141
7246
  state.candidates.forEach((candidate, index) => {
7142
7247
  const marker = candidate.isCurrent ? ' *' : '';
7143
7248
  const status = candidate.disabled
7144
7249
  ? t(locale, 'auth_candidate_status_disabled')
7145
7250
  : t(locale, 'auth_candidate_status_enabled');
7146
- lines.push(`${index + 1}. ${candidate.name}${marker} [${status}]`);
7251
+ lines.push(`${index + 1}. ${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${marker} [${status}]`);
7147
7252
  });
7148
7253
  if (includeWeixinCopyPaste) {
7149
7254
  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 +7260,7 @@ function renderAuthListMessage(locale, state, includeWeixinCopyPaste = false) {
7155
7260
  function authChoiceKeyboard(locale, record) {
7156
7261
  const rows = record.candidates.map((candidate, index) => [
7157
7262
  {
7158
- text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${candidate.name}${candidate.disabled ? ' · off' : ''}`),
7263
+ text: clipButtonText(`${candidate.isCurrent ? '✅ ' : '🔐 '}${formatAuthQuotaPrefix(candidate.quota)}|${candidate.name}${candidate.disabled ? ' · off' : ''}`),
7159
7264
  callback_data: `auth:${record.localId}:${index}`,
7160
7265
  },
7161
7266
  {
@@ -7692,6 +7797,44 @@ function formatUsagePercent(value) {
7692
7797
  }
7693
7798
  return formatCompactNumber(value);
7694
7799
  }
7800
+ function formatRemainingUsagePercent(usedPercent) {
7801
+ const remainingPercent = remainingUsagePercent(usedPercent);
7802
+ return remainingPercent === null ? '?' : formatUsagePercent(remainingPercent);
7803
+ }
7804
+ function authQuotaSnapshotFromRateLimit(snapshot) {
7805
+ return {
7806
+ capturedAtMs: Date.now(),
7807
+ primaryRemainingPercent: snapshot.primary ? remainingUsagePercent(snapshot.primary.usedPercent) : null,
7808
+ secondaryRemainingPercent: snapshot.secondary ? remainingUsagePercent(snapshot.secondary.usedPercent) : null,
7809
+ };
7810
+ }
7811
+ function remainingUsagePercent(usedPercent) {
7812
+ if (!Number.isFinite(usedPercent)) {
7813
+ return null;
7814
+ }
7815
+ return Math.max(0, Math.min(100, 100 - usedPercent));
7816
+ }
7817
+ function formatAuthQuotaPrefix(snapshot) {
7818
+ if (!snapshot) {
7819
+ return '--|--';
7820
+ }
7821
+ const primary = snapshot.primaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.primaryRemainingPercent);
7822
+ const secondary = snapshot.secondaryRemainingPercent === null ? '--' : formatUsagePercent(snapshot.secondaryRemainingPercent);
7823
+ return `${primary}|${secondary}`;
7824
+ }
7825
+ function isCodexAuthQuotaSnapshot(value) {
7826
+ if (!value || typeof value !== 'object') {
7827
+ return false;
7828
+ }
7829
+ const snapshot = value;
7830
+ return typeof snapshot.capturedAtMs === 'number'
7831
+ && Number.isFinite(snapshot.capturedAtMs)
7832
+ && isNullableFiniteNumber(snapshot.primaryRemainingPercent)
7833
+ && isNullableFiniteNumber(snapshot.secondaryRemainingPercent);
7834
+ }
7835
+ function isNullableFiniteNumber(value) {
7836
+ return value === null || (typeof value === 'number' && Number.isFinite(value));
7837
+ }
7695
7838
  function formatCompactNumber(value) {
7696
7839
  return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '');
7697
7840
  }
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
114
  readonly status_codex_local_tokens: "Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}";
115
115
  readonly status_codex_local_speed: "Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)";
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
678
  readonly status_codex_local_tokens: "Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}";
676
679
  readonly status_codex_local_speed: "Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)";
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
112
  status_codex_local_tokens: 'Codex local tokens: total {total}; input {input}, output {output}, cached input {cached}, reasoning output {reasoning}',
113
113
  status_codex_local_speed: 'Codex local output speed: avg {avg} token/s, latest {latest} token/s ({samples} samples)',
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
676
  status_codex_local_tokens: 'Codex 本地 Token:总计 {total};输入 {input},输出 {output},缓存输入 {cached},推理输出 {reasoning}',
674
677
  status_codex_local_speed: 'Codex 本地输出速度:平均 {avg} token/s,最近 {latest} token/s({samples} 个样本)',
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}',
package/dist/main.js CHANGED
@@ -9,7 +9,7 @@ import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getL
9
9
  import { acquireProcessLock, LockHeldError } from './lock.js';
10
10
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
11
11
  import { refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns } from './systemd.js';
12
- import { createSelfUpdateRuntime, performSelfUpdate } from './update.js';
12
+ import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate } from './update.js';
13
13
  const rawCommand = process.argv[2];
14
14
  const command = rawCommand || 'serve';
15
15
  loadEnv();
@@ -776,9 +776,17 @@ function stopLaunchd() {
776
776
  console.log(`Stopped ${plist}`);
777
777
  }
778
778
  function buildServicePath(nodeDir) {
779
+ const pnpmPath = resolveCommand('pnpm');
780
+ const inferredPnpmHome = inferPnpmHomeFromEntryPoint(entryPoint) || '';
781
+ const configuredPnpmHome = process.env.PNPM_HOME?.trim() || '';
779
782
  const parts = [
780
783
  path.join(process.env.HOME || '', '.local', 'bin'),
781
784
  nodeDir,
785
+ inferredPnpmHome,
786
+ inferredPnpmHome ? path.join(inferredPnpmHome, 'bin') : '',
787
+ configuredPnpmHome,
788
+ configuredPnpmHome ? path.join(configuredPnpmHome, 'bin') : '',
789
+ pnpmPath ? path.dirname(pnpmPath) : '',
782
790
  '/usr/local/sbin',
783
791
  '/usr/local/bin',
784
792
  '/usr/sbin',
package/dist/update.d.ts CHANGED
@@ -41,7 +41,8 @@ export interface SelfUpdateOutcome {
41
41
  error: string | null;
42
42
  }
43
43
  export declare function selfUpdateStatusPath(statusPath: string): string;
44
- export declare function resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean): SelfUpdateInstaller;
44
+ export declare function inferPnpmHomeFromEntryPoint(entryPoint: string): string | null;
45
+ export declare function resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean, env?: NodeJS.ProcessEnv): SelfUpdateInstaller;
45
46
  export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
46
47
  export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
47
48
  export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
package/dist/update.js CHANGED
@@ -7,21 +7,42 @@ const UPDATE_STATUS_FILENAME = 'self-update.json';
7
7
  export function selfUpdateStatusPath(statusPath) {
8
8
  return path.join(path.dirname(statusPath), UPDATE_STATUS_FILENAME);
9
9
  }
10
- export function resolveSelfUpdateInstaller(entryPoint, nodePath = process.execPath, exists = fs.existsSync) {
10
+ export function inferPnpmHomeFromEntryPoint(entryPoint) {
11
11
  const normalizedEntryPoint = entryPoint.replace(/\\/g, '/');
12
12
  const globalMarker = '/global/';
13
13
  const globalIndex = normalizedEntryPoint.indexOf(globalMarker);
14
- if (globalIndex > 0 && normalizedEntryPoint.includes('/.pnpm/')) {
15
- const pnpmHome = normalizedEntryPoint.slice(0, globalIndex);
16
- const pnpmCommand = path.join(pnpmHome, process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm');
17
- if (!exists(pnpmCommand)) {
18
- throw new Error(`Current installation is managed by pnpm, but pnpm was not found at ${pnpmCommand}.`);
14
+ if (globalIndex <= 0 || !normalizedEntryPoint.includes('/.pnpm/')) {
15
+ return null;
16
+ }
17
+ return normalizedEntryPoint.slice(0, globalIndex);
18
+ }
19
+ export function resolveSelfUpdateInstaller(entryPoint, nodePath = process.execPath, exists = fs.existsSync, env = process.env) {
20
+ const pnpmHome = inferPnpmHomeFromEntryPoint(entryPoint);
21
+ if (pnpmHome) {
22
+ const commandName = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
23
+ const candidates = executableCandidates(commandName, nodePath, env, [
24
+ path.join(pnpmHome, commandName),
25
+ path.join(pnpmHome, 'bin', commandName),
26
+ env.PNPM_HOME?.trim() ? path.join(env.PNPM_HOME.trim(), commandName) : '',
27
+ env.PNPM_HOME?.trim() ? path.join(env.PNPM_HOME.trim(), 'bin', commandName) : '',
28
+ ]);
29
+ const pnpmCommand = candidates.find((candidate) => exists(candidate));
30
+ if (pnpmCommand) {
31
+ return {
32
+ manager: 'pnpm',
33
+ command: pnpmCommand,
34
+ installArgs: ['add', '--global', PACKAGE_SPEC],
35
+ rootArgs: ['root', '--global'],
36
+ };
19
37
  }
38
+ const npmCommandName = process.platform === 'win32' ? 'npm.cmd' : 'npm';
39
+ const npmCommand = executableCandidates(npmCommandName, nodePath, env)
40
+ .find((candidate) => exists(candidate)) ?? npmCommandName;
20
41
  return {
21
42
  manager: 'pnpm',
22
- command: pnpmCommand,
23
- installArgs: ['add', '--global', PACKAGE_SPEC],
24
- rootArgs: ['root', '--global'],
43
+ command: npmCommand,
44
+ installArgs: ['exec', '--yes', '--package=pnpm@latest', '--', 'pnpm', 'add', '--global', PACKAGE_SPEC],
45
+ rootArgs: ['exec', '--yes', '--package=pnpm@latest', '--', 'pnpm', 'root', '--global'],
25
46
  };
26
47
  }
27
48
  const adjacentNpm = path.join(path.dirname(nodePath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
@@ -117,13 +138,14 @@ export function performSelfUpdate(options) {
117
138
  const env = options.env ?? process.env;
118
139
  let toVersion = null;
119
140
  try {
120
- const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath);
141
+ const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath, fs.existsSync, env);
142
+ const installerEnv = buildInstallerEnv(options.entryPoint, installer, env);
121
143
  console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
122
- runInherited(installer.command, installer.installArgs, env);
123
- const updatedEntryPoint = resolveUpdatedEntryPoint(installer, env);
144
+ runInherited(installer.command, installer.installArgs, installerEnv);
145
+ const updatedEntryPoint = resolveUpdatedEntryPoint(installer, installerEnv);
124
146
  toVersion = readInstalledPackageVersion(updatedEntryPoint);
125
147
  console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
126
- runInherited(options.nodePath, [updatedEntryPoint, 'start'], env);
148
+ runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
127
149
  completeNotification(options.notificationFile, 'succeeded', toVersion, null);
128
150
  console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
129
151
  return {
@@ -145,6 +167,32 @@ export function performSelfUpdate(options) {
145
167
  };
146
168
  }
147
169
  }
170
+ function executableCandidates(commandName, nodePath, env, preferred = []) {
171
+ return [
172
+ ...preferred,
173
+ path.join(path.dirname(nodePath), commandName),
174
+ ...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, commandName)),
175
+ ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
176
+ }
177
+ function buildInstallerEnv(entryPoint, installer, env) {
178
+ const pnpmHome = installer.manager === 'pnpm' ? inferPnpmHomeFromEntryPoint(entryPoint) : null;
179
+ if (!pnpmHome) {
180
+ return env;
181
+ }
182
+ const configuredPnpmHome = env.PNPM_HOME?.trim() || pnpmHome;
183
+ const pathEntries = [
184
+ configuredPnpmHome,
185
+ path.join(configuredPnpmHome, 'bin'),
186
+ pnpmHome,
187
+ path.join(pnpmHome, 'bin'),
188
+ ...(env.PATH || '').split(path.delimiter).filter(Boolean),
189
+ ];
190
+ return {
191
+ ...env,
192
+ PNPM_HOME: configuredPnpmHome,
193
+ PATH: pathEntries.filter((entry, index, all) => all.indexOf(entry) === index).join(path.delimiter),
194
+ };
195
+ }
148
196
  function runInherited(command, args, env) {
149
197
  const result = spawnSync(command, args, { stdio: 'inherit', env });
150
198
  if (result.error) {
@@ -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 output-speed metrics use a background-generated historical snapshot instead of scanning large logs during the request.
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/输出速度使用后台生成的历史快照,避免状态查询现场扫描大量日志。
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.14",
3
+ "version": "0.3.17",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",