From 7776715ebadc5821167c0bf721266f43a228b3cb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 11 Apr 2026 17:50:11 -0400 Subject: [PATCH 1/8] fix(docker): delegate restart/install to supervisorctl in Docker deployments The dashboard restart button and install service used local-mode process management (stopProxy + ensureCliproxyService) which conflicts with supervisord in Docker deployments. Killing the CLIProxy binary caused the bootstrap wrapper to exit, supervisord to attempt a restart, but ensureCliproxyService already spawned an orphaned process on :8317 resulting in EADDRINUSE and supervisord entering FATAL state. Detect supervisord via /var/run/supervisor.sock and delegate to supervisorctl restart, matching the pattern already used by ccs docker update. Closes #964 --- src/docker/supervisord-lifecycle.ts | 36 +++++++++++++++++++ .../routes/cliproxy-stats-routes.ts | 13 ++++++- .../cliproxy-dashboard-install-service.ts | 18 ++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/docker/supervisord-lifecycle.ts diff --git a/src/docker/supervisord-lifecycle.ts b/src/docker/supervisord-lifecycle.ts new file mode 100644 index 00000000..21ca5d0f --- /dev/null +++ b/src/docker/supervisord-lifecycle.ts @@ -0,0 +1,36 @@ +/** + * Supervisord lifecycle helpers for Docker deployments (`ccs docker up`). + * + * In Docker, supervisord owns the CLIProxy process lifecycle. Direct + * stop+start via session-tracker / service-manager creates orphaned + * processes and causes supervisord to enter FATAL state (EADDRINUSE). + * All restart operations must delegate to `supervisorctl` instead. + */ + +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; + +const SUPERVISOR_SOCK = '/var/run/supervisor.sock'; +const SUPERVISOR_CONF = '/etc/supervisord.conf'; + +/** True when running inside a supervisord-managed container. */ +export function isRunningUnderSupervisord(): boolean { + return fs.existsSync(SUPERVISOR_SOCK); +} + +/** Restart the cliproxy program via supervisorctl. Returns port on success. */ +export function restartCliproxyViaSupervisord(): { + success: boolean; + port?: number; + error?: string; +} { + try { + execSync(`supervisorctl -c ${SUPERVISOR_CONF} restart cliproxy`, { timeout: 15_000 }); + return { success: true, port: CLIPROXY_DEFAULT_PORT }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error(`[cliproxy] supervisorctl restart failed: ${detail}`); + return { success: false, error: 'supervisorctl restart failed' }; + } +} diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a6bd9d44..a3fc3b05 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -5,6 +5,10 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import { + isRunningUnderSupervisord, + restartCliproxyViaSupervisord, +} from '../../docker/supervisord-lifecycle'; import { fetchCliproxyStats, fetchCliproxyModels, @@ -1014,7 +1018,14 @@ router.post('/install', async (req: Request, res: Response): Promise => { */ router.post('/restart', async (_req: Request, res: Response): Promise => { try { - // Stop proxy first + if (isRunningUnderSupervisord()) { + // Docker mode: delegate to supervisord which owns the process lifecycle + const result = restartCliproxyViaSupervisord(); + res.json(result); + return; + } + + // Local mode: direct process management await stopProxy(); // Small delay to ensure port is released diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 7091965f..8ea78bb5 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -3,6 +3,10 @@ import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/s import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; import type { CLIProxyBackend } from '../../cliproxy/types'; +import { + isRunningUnderSupervisord, + restartCliproxyViaSupervisord, +} from '../../docker/supervisord-lifecycle'; interface ProxyStatusLike { running: boolean; @@ -63,6 +67,20 @@ export async function installDashboardCliproxyVersion( }; } + // In Docker, supervisord owns process lifecycle — delegate restart to it + if (isRunningUnderSupervisord()) { + const result = restartCliproxyViaSupervisord(); + return { + success: result.success, + restarted: result.success, + port: result.port, + error: result.error, + message: result.success + ? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}` + : `Installed ${backendLabel} v${version}, but restart failed`, + }; + } + const startResult = await deps.ensureCliproxyService(); if (!startResult.started && !startResult.alreadyRunning) { return { From ca8401e5fc18b4bd3d577704d7de8411253084cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 22:13:58 +0000 Subject: [PATCH 2/8] chore(release): 7.68.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a58575f5..02d4251f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.0", + "version": "7.68.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 5f8e66ec1e1ef9cceb135c560f9bd44c9269dd6b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:22:15 -0400 Subject: [PATCH 3/8] hotfix: detect bun-owned installs through symlinked ccs path --- src/utils/package-manager-detector.ts | 4 ++- .../utils/package-manager-detector.test.ts | 35 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index 606c58ed..d5f4a051 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -29,7 +29,9 @@ export interface InstalledPackageState { const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; function resolveScriptPath(scriptPath: string): string { - if (path.win32.isAbsolute(scriptPath)) { + // Keep Windows-style absolute test fixtures stable on non-Windows hosts, but + // still resolve real POSIX symlink paths such as ~/.bun/bin/ccs. + if (path.win32.isAbsolute(scriptPath) && !path.isAbsolute(scriptPath)) { return scriptPath; } diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts index 693e0920..5bb1164a 100644 --- a/tests/unit/utils/package-manager-detector.test.ts +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { @@ -72,6 +72,39 @@ describe('package-manager-detector', () => { expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); }); + it.if(process.platform !== 'win32')( + 'detects bun installs from a POSIX symlinked ~/.bun/bin/ccs entrypoint', + () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-symlink-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + const symlinkPath = join(tempRoot, 'home', '.bun', 'bin', 'ccs'); + + writePackage(packageRoot, '7.67.0-dev.9'); + mkdirSync(join(packageRoot, 'dist'), { recursive: true }); + writeFileSync(scriptPath, '#!/usr/bin/env node\n'); + mkdirSync(join(tempRoot, 'home', '.bun', 'bin'), { recursive: true }); + symlinkSync(scriptPath, symlinkPath); + + const install = detectCurrentInstall(symlinkPath); + const resolvedTempRoot = realpathSync(tempRoot); + const resolvedScriptPath = realpathSync(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(resolvedTempRoot, '.bun')); + expect(install.resolvedScriptPath).toBe(resolvedScriptPath); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + } + ); + it('detects custom bun install roots that still use install/global/node_modules', () => { const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); const packageRoot = join( From 5076b212af6a9318505e52536899af1060faf053 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:24:40 -0400 Subject: [PATCH 4/8] fix(ui): clarify Gemini quota tooltip labels --- .../shared/quota-tooltip-content.tsx | 132 +++++++++++++++--- .../shared/quota-tooltip-content.test.tsx | 24 ++-- 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index bd1e14dd..b653dde7 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -78,6 +78,63 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s } } +function formatGeminiTokenType(tokenType: string | null | undefined): string | null { + if (!tokenType) return null; + + switch (tokenType.trim().toLowerCase()) { + case 'requests': + return 'Requests'; + case 'input': + return 'Input tokens'; + case 'output': + return 'Output tokens'; + default: + return tokenType + .split(/[\s_-]+/g) + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + } +} + +function formatGeminiBucketLabel(label: string): string { + switch (label) { + case 'Gemini Flash Lite Series': + return 'Flash Lite'; + case 'Gemini Flash Series': + return 'Flash'; + case 'Gemini Pro Series': + return 'Pro'; + default: + return label; + } +} + +function formatGeminiBucketModels(modelIds: string[] | undefined): string | null { + const uniqueModelIds = Array.from(new Set((modelIds || []).filter(Boolean))); + return uniqueModelIds.length > 0 ? uniqueModelIds.join(', ') : null; +} + +function formatGeminiRemainingAmount( + remainingAmount: number | null | undefined, + tokenType: string | null | undefined +): string | null { + if (remainingAmount === null || remainingAmount === undefined) return null; + + const formattedAmount = remainingAmount.toLocaleString(); + switch (tokenType?.trim().toLowerCase()) { + case 'requests': + return `${formattedAmount} requests remaining`; + case 'input': + return `${formattedAmount} input tokens remaining`; + case 'output': + return `${formattedAmount} output tokens remaining`; + default: + return `${formattedAmount} remaining`; + } +} + function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) { if (!entitlement) return null; @@ -301,6 +358,14 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); const hasEntitlementTier = !!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown'; + const distinctTokenTypes = Array.from( + new Set( + quota.buckets + .map((bucket) => formatGeminiTokenType(bucket.tokenType)) + .filter((tokenType): tokenType is string => !!tokenType) + ) + ); + const sharedTokenType = distinctTokenTypes.length === 1 ? distinctTokenTypes[0] : null; return (
@@ -317,28 +382,53 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {quota.creditBalance.toLocaleString()}
)} -

Buckets:

- {quota.buckets.map((b) => ( -
-
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% -
- {((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && ( -
- - {b.remainingAmount !== null && b.remainingAmount !== undefined - ? `${b.remainingAmount.toLocaleString()} remaining` - : ''} - - {formatAbsoluteResetTime(b.resetTime) ?? ''} +
+

Model quotas:

+ {sharedTokenType && ( +

+ All buckets report {sharedTokenType} +

+ )} +
+ {quota.buckets.map((bucket) => { + const bucketTokenType = sharedTokenType ? null : formatGeminiTokenType(bucket.tokenType); + const bucketModels = formatGeminiBucketModels(bucket.modelIds); + const remainingAmountLabel = formatGeminiRemainingAmount( + bucket.remainingAmount, + bucket.tokenType + ); + + return ( +
+
+
+
+ + {formatGeminiBucketLabel(bucket.label)} + + {bucketTokenType && ( + + {bucketTokenType} + + )} +
+ {bucketModels && ( +
+ {bucketModels} +
+ )} +
+ {bucket.remainingPercent}%
- )} -
- ))} + {(remainingAmountLabel || bucket.resetTime) && ( +
+ {remainingAmountLabel ?? ''} + {formatAbsoluteResetTime(bucket.resetTime) ?? ''} +
+ )} +
+ ); + })} {!hasBucketResetTime && }
); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index 790ec8a1..bf600694 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -12,22 +12,22 @@ function createGeminiQuotaResult( { id: 'gemini-flash-lite-series::combined', label: 'Gemini Flash Lite Series', - tokenType: null, + tokenType: 'requests', remainingFraction: 1, remainingPercent: 100, remainingAmount: 100, resetTime: '2026-01-30T09:00:00Z', - modelIds: ['gemini-2.5-flash-lite'], + modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'], }, { id: 'gemini-flash-series::combined', label: 'Gemini Flash Series', - tokenType: null, + tokenType: 'requests', remainingFraction: 0.82, remainingPercent: 82, remainingAmount: 82, resetTime: '2026-01-30T14:00:00Z', - modelIds: ['gemini-3-flash-preview'], + modelIds: ['gemini-3-flash-preview', 'gemini-3.1-flash-preview', 'gemini-2.5-flash'], }, ], projectId: 'cloudaicompanion-test-123', @@ -51,7 +51,7 @@ function createGeminiQuotaResult( } describe('QuotaTooltipContent', () => { - it('renders Gemini tier, credits, remaining amount, and bucket reset timestamps', () => { + it('renders Gemini tier, model coverage, and clearer bucket wording', () => { const quota = createGeminiQuotaResult(); const expectedReset = new Date('2026-01-30T14:00:00Z').toLocaleString(undefined, { month: '2-digit', @@ -69,9 +69,17 @@ describe('QuotaTooltipContent', () => { expect(screen.getByText('g1-pro-tier')).toBeInTheDocument(); expect(screen.getByText('Credits')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); - expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument(); - expect(screen.getByText('100 remaining')).toBeInTheDocument(); - expect(screen.getByText('82 remaining')).toBeInTheDocument(); + expect(screen.getByText('Model quotas:')).toBeInTheDocument(); + expect(screen.getByText('All buckets report Requests')).toBeInTheDocument(); + expect(screen.getByText('Flash Lite')).toBeInTheDocument(); + expect( + screen.getByText('gemini-2.5-flash-lite, gemini-3.1-flash-lite-preview') + ).toBeInTheDocument(); + expect(screen.getByText('100 requests remaining')).toBeInTheDocument(); + expect( + screen.getByText('gemini-3-flash-preview, gemini-3.1-flash-preview, gemini-2.5-flash') + ).toBeInTheDocument(); + expect(screen.getByText('82 requests remaining')).toBeInTheDocument(); expect(screen.getByText(expectedReset)).toBeInTheDocument(); }); From 898e7a632125476f269d4ff34280589404d7c3ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 12 Apr 2026 01:22:15 -0400 Subject: [PATCH 5/8] hotfix: detect bun-owned installs through symlinked ccs path --- src/utils/package-manager-detector.ts | 4 ++- .../utils/package-manager-detector.test.ts | 35 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index 606c58ed..d5f4a051 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -29,7 +29,9 @@ export interface InstalledPackageState { const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; function resolveScriptPath(scriptPath: string): string { - if (path.win32.isAbsolute(scriptPath)) { + // Keep Windows-style absolute test fixtures stable on non-Windows hosts, but + // still resolve real POSIX symlink paths such as ~/.bun/bin/ccs. + if (path.win32.isAbsolute(scriptPath) && !path.isAbsolute(scriptPath)) { return scriptPath; } diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts index 693e0920..5bb1164a 100644 --- a/tests/unit/utils/package-manager-detector.test.ts +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { @@ -72,6 +72,39 @@ describe('package-manager-detector', () => { expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); }); + it.if(process.platform !== 'win32')( + 'detects bun installs from a POSIX symlinked ~/.bun/bin/ccs entrypoint', + () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-symlink-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + const symlinkPath = join(tempRoot, 'home', '.bun', 'bin', 'ccs'); + + writePackage(packageRoot, '7.67.0-dev.9'); + mkdirSync(join(packageRoot, 'dist'), { recursive: true }); + writeFileSync(scriptPath, '#!/usr/bin/env node\n'); + mkdirSync(join(tempRoot, 'home', '.bun', 'bin'), { recursive: true }); + symlinkSync(scriptPath, symlinkPath); + + const install = detectCurrentInstall(symlinkPath); + const resolvedTempRoot = realpathSync(tempRoot); + const resolvedScriptPath = realpathSync(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(resolvedTempRoot, '.bun')); + expect(install.resolvedScriptPath).toBe(resolvedScriptPath); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + } + ); + it('detects custom bun install roots that still use install/global/node_modules', () => { const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); const packageRoot = join( From d725a3c06183c949b7598ff0145c0d5fd3e9d2d1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 12 Apr 2026 05:34:46 +0000 Subject: [PATCH 6/8] chore(release): 7.68.1 [skip ci] ## [7.68.1](https://github.com/kaitranntt/ccs/compare/v7.68.0...v7.68.1) (2026-04-12) ### Hotfixes * detect bun-owned installs through symlinked ccs path ([5f8e66e](https://github.com/kaitranntt/ccs/commit/5f8e66ec1e1ef9cceb135c560f9bd44c9269dd6b)) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0df112..f7d62b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [7.68.1](https://github.com/kaitranntt/ccs/compare/v7.68.0...v7.68.1) (2026-04-12) + +### Hotfixes + +* detect bun-owned installs through symlinked ccs path ([5f8e66e](https://github.com/kaitranntt/ccs/commit/5f8e66ec1e1ef9cceb135c560f9bd44c9269dd6b)) + ## [7.68.0](https://github.com/kaitranntt/ccs/compare/v7.67.0...v7.68.0) (2026-04-11) ### Features diff --git a/package.json b/package.json index a58575f5..9fd19432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.0", + "version": "7.68.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From b2f584114a19fa3f3b0882af4a91b9c67316c603 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 05:37:37 +0000 Subject: [PATCH 7/8] chore(release): 7.68.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fd19432..e604f9ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1", + "version": "7.68.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 2eaf760d20be35c958e31ae16b362d0c508cd0c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 05:47:44 +0000 Subject: [PATCH 8/8] chore(release): 7.68.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e604f9ed..7bb6ae59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.68.1-dev.1", + "version": "7.68.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",