From 5122a11b34c99842b3a7c5a8f4e581829b031f69 Mon Sep 17 00:00:00 2001 From: rlawjdghksdlqslek Date: Sun, 3 May 2026 20:44:21 +0900 Subject: [PATCH 1/2] Persist quota state across server restarts --- src/account-manager.js | 23 ++++++++++++++++++++++- src/index.js | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/account-manager.js b/src/account-manager.js index 9ea4d78..996093b 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -28,7 +28,7 @@ export class AccountManager { refreshToken: acct.refreshToken || null, expiresAt: acct.expiresAt || null, status: 'active', - quota: emptyQuota(), + quota: acct.savedQuota ? { ...emptyQuota(), ...acct.savedQuota } : emptyQuota(), usage: { totalInputTokens: 0, totalOutputTokens: 0, @@ -206,6 +206,27 @@ export class AccountManager { } } + /** + * Export serializable quota state for a single account — safe to persist to config. + */ + exportQuota(accountIndex) { + const account = this.accounts[accountIndex]; + if (!account) return null; + const q = account.quota; + return { + unified5h: q.unified5h, + unified7d: q.unified7d, + unified5hReset: q.unified5hReset, + unified7dReset: q.unified7dReset, + unifiedStatus: q.unifiedStatus, + tokensLimit: q.tokensLimit, + tokensRemaining: q.tokensRemaining, + requestsLimit: q.requestsLimit, + requestsRemaining: q.requestsRemaining, + resetsAt: q.resetsAt, + }; + } + /** * Update cumulative token usage from response body data. */ diff --git a/src/index.js b/src/index.js index b4aa655..b3628ca 100755 --- a/src/index.js +++ b/src/index.js @@ -120,6 +120,25 @@ async function serverCommand() { } }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`)); }); + + async function persistQuotaState() { + await atomicConfigUpdate(diskConfig => { + for (const account of accountManager.accounts) { + const cfgIdx = findConfigAccount(diskConfig, account); + if (cfgIdx >= 0) { + diskConfig.accounts[cfgIdx].savedQuota = accountManager.exportQuota(account.index); + } + } + }); + } + + const quotaSaveInterval = setInterval( + () => persistQuotaState().catch(err => + console.error(`[TeamClaude] Failed to save quota state: ${err.message}`) + ), + 60_000 + ); + const port = config.proxy.port; const useTUI = process.stdout.isTTY && process.stdin.isTTY; @@ -152,7 +171,13 @@ async function serverCommand() { if (!diskConfig) return 0; return syncAccountsFromDisk(diskConfig, config, accountManager); }, - onQuit: () => { server.close(() => process.exit(0)); }, + onQuit: async () => { + clearInterval(quotaSaveInterval); + await persistQuotaState().catch(err => + console.error(`[TeamClaude] Failed to save quota on quit: ${err.message}`) + ); + server.close(() => process.exit(0)); + }, }); hooks = { onRequestStart: (id, info) => tui.onRequestStart(id, info), @@ -190,12 +215,20 @@ async function serverCommand() { }); if (!tui) { - process.on('SIGINT', () => { + process.on('SIGINT', async () => { console.log('\n[TeamClaude] Shutting down...'); + clearInterval(quotaSaveInterval); + await persistQuotaState().catch(err => + console.error(`[TeamClaude] Failed to save quota on shutdown: ${err.message}`) + ); server.close(() => process.exit(0)); }); - process.on('SIGTERM', () => { + process.on('SIGTERM', async () => { console.log('\n[TeamClaude] Shutting down...'); + clearInterval(quotaSaveInterval); + await persistQuotaState().catch(err => + console.error(`[TeamClaude] Failed to save quota on shutdown: ${err.message}`) + ); server.close(() => process.exit(0)); }); } From fdec49fa77c13a964d1c66b08eedc4e678f7ae42 Mon Sep 17 00:00:00 2001 From: rlawjdghksdlqslek Date: Sun, 3 May 2026 20:51:47 +0900 Subject: [PATCH 2/2] Add TUI account reorder with o key --- src/account-manager.js | 18 +++++++++++ src/tui.js | 69 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/account-manager.js b/src/account-manager.js index 996093b..075ce51 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -347,6 +347,24 @@ export class AccountManager { } } + /** + * Move an account from one index to another, updating currentIndex accordingly. + */ + moveAccount(from, to) { + if (from === to || from < 0 || to < 0 || + from >= this.accounts.length || to >= this.accounts.length) return; + const [account] = this.accounts.splice(from, 1); + this.accounts.splice(to, 0, account); + this.accounts.forEach((a, i) => a.index = i); + if (this.currentIndex === from) { + this.currentIndex = to; + } else if (from < to) { + if (this.currentIndex > from && this.currentIndex <= to) this.currentIndex--; + } else { + if (this.currentIndex >= to && this.currentIndex < from) this.currentIndex++; + } + } + /** * Return a status summary of all accounts (safe to expose, no credentials). */ diff --git a/src/tui.js b/src/tui.js index 503820c..bc14e33 100644 --- a/src/tui.js +++ b/src/tui.js @@ -126,6 +126,9 @@ export class TUI { this.mode = 'normal'; // normal | select | add | input this.selAction = null; // switch | remove this.selIdx = 0; + this.reorderGrabbed = null; + this.reorderOrigOrder = null; + this.reorderOrigCurrentIdx = null; this.inputPrompt = ''; this.inputBuf = ''; this.inputCb = null; @@ -216,10 +219,11 @@ export class TUI { if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; } switch (this.mode) { - case 'normal': this._keyNormal(k); break; - case 'select': this._keySelect(k); break; - case 'add': this._keyAdd(k); break; - case 'input': this._keyInput(k); break; + case 'normal': this._keyNormal(k); break; + case 'select': this._keySelect(k); break; + case 'reorder': this._keyReorder(k); break; + case 'add': this._keyAdd(k); break; + case 'input': this._keyInput(k); break; } this.render(); } @@ -234,6 +238,10 @@ export class TUI { } else if (k === 'a') { this.mode = 'add'; } else if (k === 'R') { this._doSync(); } + else if (k === 'o' && this.am.accounts.length > 1) { + this.mode = 'reorder'; this.selIdx = this.am.currentIndex; + this.reorderGrabbed = null; this.reorderOrigOrder = null; + } } _keySelect(k) { @@ -252,6 +260,36 @@ export class TUI { else if (k === 'esc' || k === 'q') { this.mode = 'normal'; } } + _keyReorder(k) { + const len = this.am.accounts.length; + if (this.reorderGrabbed === null) { + if (k === 'up' || k === 'k') this.selIdx = Math.max(0, this.selIdx - 1); + else if (k === 'down' || k === 'j') this.selIdx = Math.min(len - 1, this.selIdx + 1); + else if (k === 'enter') { + this.reorderGrabbed = this.selIdx; + this.reorderOrigOrder = [...this.am.accounts]; + this.reorderOrigCurrentIdx = this.am.currentIndex; + } + else if (k === 'esc' || k === 'q') { this.mode = 'normal'; } + } else { + if ((k === 'up' || k === 'k') && this.selIdx > 0) { + this.am.moveAccount(this.selIdx, this.selIdx - 1); + this.selIdx--; + } else if ((k === 'down' || k === 'j') && this.selIdx < len - 1) { + this.am.moveAccount(this.selIdx, this.selIdx + 1); + this.selIdx++; + } else if (k === 'enter') { + this._doSaveReorder(); + this.mode = 'normal'; this.reorderGrabbed = null; this.reorderOrigOrder = null; + } else if (k === 'esc' || k === 'q') { + this.am.accounts.splice(0, this.am.accounts.length, ...this.reorderOrigOrder); + this.am.accounts.forEach((a, i) => a.index = i); + this.am.currentIndex = this.reorderOrigCurrentIdx; + this.mode = 'normal'; this.reorderGrabbed = null; this.reorderOrigOrder = null; + } + } + } + _keyAdd(k) { if (k === 'i') { this._doImport(); this.mode = 'normal'; } else if (k === 'k') { @@ -369,6 +407,17 @@ export class TUI { this._addLog(`Removed account "${name}"`); } + async _doSaveReorder() { + const reordered = this.am.accounts.map(a => + this.config.accounts.find(c => + (a.accountUuid && c.accountUuid === a.accountUuid) || c.name === a.name + ) + ).filter(Boolean); + this.config.accounts = reordered; + await this.saveConfig(this.config); + this._addLog('Account order saved'); + } + // ── rendering ────────────────────────────────────── render() { @@ -450,10 +499,11 @@ export class TUI { _renderAcct(idx, bw, showBoth) { const a = this.am.accounts[idx]; const isCur = idx === this.am.currentIndex; - const isSel = this.mode === 'select' && idx === this.selIdx; + const isSel = (this.mode === 'select' || this.mode === 'reorder') && idx === this.selIdx; + const isGrabbed = this.mode === 'reorder' && this.reorderGrabbed !== null && idx === this.selIdx; // Prefix: selection marker + current marker - const sel = isSel ? cyan('>') : ' '; + const sel = isGrabbed ? cyan('↕') : isSel ? cyan('>') : ' '; const cur = isCur ? green('►') : ' '; // Name (bold if selected) @@ -504,11 +554,16 @@ export class TUI { _renderFooter() { switch (this.mode) { case 'normal': - return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`; + return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('o')}rder ${bold('R')}eload ${bold('q')}uit`; case 'select': { const act = this.selAction === 'switch' ? 'switch' : 'remove'; return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`; } + case 'reorder': + if (this.reorderGrabbed === null) { + return ` ${dim('↑↓')} select ${bold('Enter')} grab ${bold('Esc')} cancel`; + } + return ` ${dim('↑↓')} move ${bold('Enter')} confirm ${bold('Esc')} cancel`; case 'add': return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`; case 'input':